Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
Shared runtime - One agent entry point (runtime.invoke_agent_for_user / prepare_agent_turn) for web, email, iMessage, WhatsApp, scheduled and cron, so identity, permissions, memories, summaries and metrics match across channels. - Serialize turns per checkpoint thread; /query validates input and returns 409 while a durable run owns the conversation. Reconnectable chat (durable runs) - POST /runs creates an idempotent, owner-scoped run; GET .../events replays an ordered SSE log by cursor, so generation survives refresh and device switches. Stop cancels; disconnect only detaches. /stream-query is a compatibility adapter. - Coalesce streamed deltas (150 ms) and wake same-process subscribers instead of polling; keep buffered text when a run is stopped. - Drain runs on shutdown; serve via Hypercorn with a shutdown timeout covering the drain (and no longer in debug mode). CHAT_RUN_* settings are forwarded. Scheduled messages and cron - Atomic delivery claims, attempt history and explicit retries; ambiguous sends become "uncertain" and are never resent automatically. - Cron triggers need a run_id/Idempotency-Key; legacy jobs need an explicit owner. - Cancelling never rewrites a started delivery; series stop via tombstones. Legacy sent rows are not forked into new series. Malformed bodies return 400. History, integrations and retrieval - Message-content search, pinned conversations, JSON/Markdown export and a memory editor. - Integration status panel and YNAB data-freshness provenance. - Date-filtered document/note search with stable citations and a source viewer. Migration 20 is generated by aerich. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -41,6 +41,12 @@ TOOL_ROUTING_MODE=static
|
||||
# Optional durable summary sidecar; defaults to CHECKPOINT_DB_PATH + .summaries.db
|
||||
# Store on the same persistent shared volume as the checkpoints.
|
||||
# CONVERSATION_SUMMARY_DB_PATH=/app/checkpoints/conversation-summaries.db
|
||||
# Durable chat runs (see docs/chat-runs.md). Lease = abandoned-worker timeout;
|
||||
# max = whole generation; drain = shutdown wait for in-flight generations.
|
||||
# scripts/deploy.sh gives the old container drain + 60s (override DEPLOY_STOP_SECONDS).
|
||||
CHAT_RUN_LEASE_SECONDS=30
|
||||
CHAT_RUN_MAX_SECONDS=1800
|
||||
CHAT_RUN_DRAIN_SECONDS=1800
|
||||
|
||||
# Embedding Server Configuration
|
||||
# If set, uses a custom OpenAI-compatible embedding server (e.g. llama-server)
|
||||
|
||||
@@ -17,6 +17,14 @@ from quart_jwt_extended import JWTManager, get_jwt_identity
|
||||
from tortoise import Tortoise
|
||||
|
||||
import blueprints.artifacts
|
||||
import blueprints.integrations
|
||||
from blueprints.conversation.history import history_blueprint
|
||||
from blueprints.conversation.runs import (
|
||||
drain_runs,
|
||||
expire_runs,
|
||||
runs_blueprint,
|
||||
)
|
||||
from blueprints.rag.source_api import sources_blueprint
|
||||
import blueprints.conversation
|
||||
import blueprints.conversation.logic
|
||||
import blueprints.email
|
||||
@@ -75,6 +83,10 @@ app.register_blueprint(blueprints.scheduled_messages.scheduled_messages_blueprin
|
||||
app.register_blueprint(blueprints.cron.cron_blueprint)
|
||||
app.register_blueprint(blueprints.beli.beli_blueprint)
|
||||
app.register_blueprint(blueprints.artifacts.artifacts_blueprint)
|
||||
app.register_blueprint(blueprints.integrations.integrations_blueprint)
|
||||
app.register_blueprint(history_blueprint)
|
||||
app.register_blueprint(runs_blueprint)
|
||||
app.register_blueprint(sources_blueprint)
|
||||
|
||||
|
||||
async def _obsidian_sync_loop():
|
||||
@@ -104,6 +116,7 @@ async def lifespan():
|
||||
logging.info("Initializing Tortoise ORM...")
|
||||
await Tortoise.init(config=TORTOISE_CONFIG)
|
||||
logging.info("Tortoise ORM initialized successfully")
|
||||
await expire_runs()
|
||||
|
||||
import aiosqlite
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
@@ -138,6 +151,9 @@ async def lifespan():
|
||||
|
||||
yield
|
||||
|
||||
# Let in-flight chat runs finish while the agent and checkpointer are alive.
|
||||
await drain_runs(app)
|
||||
|
||||
for task in background_tasks:
|
||||
task.cancel()
|
||||
|
||||
@@ -254,4 +270,15 @@ async def get_messages():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8080, debug=True)
|
||||
from hypercorn.asyncio import serve
|
||||
from hypercorn.config import Config
|
||||
|
||||
config = Config()
|
||||
config.bind = ["0.0.0.0:8080"]
|
||||
config.accesslog = "-"
|
||||
config.errorlog = "-"
|
||||
# SSE subscribers reconnect to the new deployment, so connections close
|
||||
# quickly; lifespan shutdown must outlast chat-run draining (drain_runs).
|
||||
config.graceful_timeout = 10
|
||||
config.shutdown_timeout = float(os.getenv("CHAT_RUN_DRAIN_SECONDS", "1800")) + 30
|
||||
asyncio.run(serve(app, config))
|
||||
|
||||
@@ -7,8 +7,7 @@ import time
|
||||
import uuid
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, make_response, request
|
||||
from langchain_core.messages import convert_to_messages
|
||||
from quart import Blueprint, current_app, jsonify, request
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
|
||||
import blueprints.users.models
|
||||
@@ -35,11 +34,11 @@ from utils.s3_client import upload_image as s3_upload_image
|
||||
|
||||
from .logic import (
|
||||
add_message_to_conversation,
|
||||
get_agent_for,
|
||||
persist_agent_tool_messages,
|
||||
tool_result_text,
|
||||
)
|
||||
from .memory import build_system_prompt_with_memories
|
||||
from . import runtime
|
||||
from .runtime import prepare_summary as _prepare_conversation_summary # noqa: F401 — compatibility export
|
||||
from .models import (
|
||||
Conversation,
|
||||
PydConversation,
|
||||
@@ -135,17 +134,6 @@ conversation_blueprint = Blueprint(
|
||||
"conversation_api", __name__, url_prefix="/api/conversation"
|
||||
)
|
||||
|
||||
# Detached persistence tasks. A strong reference keeps them from being garbage
|
||||
# collected mid-flight when the SSE stream is cancelled by a client disconnect.
|
||||
_persist_tasks: set[asyncio.Task] = set()
|
||||
|
||||
|
||||
def _persist_in_background(coro) -> asyncio.Task:
|
||||
task = asyncio.create_task(coro)
|
||||
_persist_tasks.add(task)
|
||||
task.add_done_callback(_persist_tasks.discard)
|
||||
return task
|
||||
|
||||
|
||||
async def _get_owned_conversation(conversation_id, user_id) -> Conversation | None:
|
||||
"""Fetch a conversation only if it belongs to the given user."""
|
||||
@@ -171,38 +159,30 @@ def _build_user_message(
|
||||
return {"role": "user", "content": content}
|
||||
|
||||
|
||||
async def _prepare_conversation_summary(agent, payload, config) -> None:
|
||||
"""Archive completed checkpoint turns before adding this request's message."""
|
||||
store = getattr(current_app, "conversation_summary_store", None)
|
||||
if store is None:
|
||||
return
|
||||
configurable = config["configurable"]
|
||||
try:
|
||||
checkpoint = await agent.aget_state(config)
|
||||
history = convert_to_messages(checkpoint.values.get("messages", []))
|
||||
state = await store.update(
|
||||
configurable["user_id"],
|
||||
configurable["thread_id"],
|
||||
history + convert_to_messages(payload["messages"]),
|
||||
)
|
||||
configurable["conversation_summary"] = state.as_message()
|
||||
except Exception:
|
||||
# The original checkpoint still supplies recent history if the optional
|
||||
# summary store is unavailable. Don't fail an otherwise valid request.
|
||||
logging.exception("Failed to prepare conversation summary")
|
||||
|
||||
|
||||
@conversation_blueprint.post("/query")
|
||||
@jwt_access_required
|
||||
async def query():
|
||||
current_user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
||||
data = await request.get_json()
|
||||
data = await request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
return jsonify({"error": "JSON object required"}), 400
|
||||
query = data.get("query")
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
return jsonify({"error": "query must be a non-empty string"}), 400
|
||||
conversation_id = data.get("conversation_id")
|
||||
if not isinstance(conversation_id, str):
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
from .run_models import ChatRun
|
||||
|
||||
# A durable run owns this checkpoint thread (possibly in another process).
|
||||
if await ChatRun.filter(active_key=str(conversation.id)).exists():
|
||||
return jsonify({"error": "A run is already active in this conversation"}), 409
|
||||
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=query,
|
||||
@@ -211,22 +191,9 @@ async def query():
|
||||
)
|
||||
|
||||
with time_response(stream=False):
|
||||
system_prompt = await build_system_prompt_with_memories(
|
||||
str(user.id), query=query or ""
|
||||
)
|
||||
payload = {"messages": [_build_user_message(query)]}
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
|
||||
main_agent = get_agent_for(user)
|
||||
await _prepare_conversation_summary(main_agent, payload, agent_config)
|
||||
response = await main_agent.ainvoke(payload, config=agent_config)
|
||||
async with runtime.thread_lock(str(conversation.id)):
|
||||
turn = await runtime.prepare_agent_turn(user, query, str(conversation.id))
|
||||
response = await runtime.invoke_prepared(turn)
|
||||
message = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
@@ -365,23 +332,31 @@ async def serve_image(image_key: str):
|
||||
@conversation_blueprint.post("/stream-query")
|
||||
@jwt_access_required
|
||||
async def stream_query():
|
||||
current_user_uuid = get_jwt_identity()
|
||||
user = await blueprints.users.models.User.get(id=current_user_uuid)
|
||||
data = await request.get_json()
|
||||
# Compatibility adapter: disconnect detaches; explicit cancellation uses /runs.
|
||||
from .runs import create_run, replay_response, RunConflict
|
||||
|
||||
try:
|
||||
run, _ = await create_run(
|
||||
current_app._get_current_object(),
|
||||
get_jwt_identity(),
|
||||
await request.get_json(),
|
||||
)
|
||||
except RunConflict as exc:
|
||||
return jsonify({"error": str(exc)}), exc.status
|
||||
return await replay_response(run, 0)
|
||||
|
||||
|
||||
async def _chat_run_stream(data, user, conversation, save_user, save_answer):
|
||||
query_text = data.get("query")
|
||||
conversation_id = data.get("conversation_id")
|
||||
image_key = data.get("image_key")
|
||||
file_key = data.get("file_key")
|
||||
conversation = await _get_owned_conversation(conversation_id, current_user_uuid)
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
|
||||
# An attached file is extracted and appended to the agent's user turn; the
|
||||
# saved message only records its name.
|
||||
saved_text = query_text or ""
|
||||
if file_key:
|
||||
if not _file_key_belongs_to(file_key, conversation.id):
|
||||
return jsonify({"error": "invalid file_key"}), 400
|
||||
raise ValueError("invalid file_key")
|
||||
try:
|
||||
file_name, file_text = await _load_attached_file(file_key)
|
||||
block = attachment_block(file_name, file_text)
|
||||
@@ -392,13 +367,7 @@ async def stream_query():
|
||||
query_text = f"{query_text or 'The user attached this file.'}\n\n{block}"
|
||||
saved_text = f"{saved_text}\n\n📎 {file_name}".strip()
|
||||
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=saved_text,
|
||||
speaker="user",
|
||||
user=user,
|
||||
image_key=image_key,
|
||||
)
|
||||
await save_user(saved_text, image_key)
|
||||
|
||||
# If an image was uploaded, attach it to the agent request directly
|
||||
image_block = None
|
||||
@@ -410,23 +379,12 @@ async def stream_query():
|
||||
logging.error(f"Failed to load image {image_key}: {e}")
|
||||
query_text = f"{query_text or ''}\n\n[An image was sent but could not be loaded.]".strip()
|
||||
|
||||
system_prompt = await build_system_prompt_with_memories(
|
||||
str(user.id), query=saved_text or ""
|
||||
turn = await runtime.prepare_agent_turn(
|
||||
user,
|
||||
saved_text or "",
|
||||
str(conversation.id),
|
||||
messages=[_build_user_message(query_text or "", image_block)],
|
||||
)
|
||||
payload = {"messages": [_build_user_message(query_text or "", image_block)]}
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
# Capture the agent reference while the request/app context is still active.
|
||||
# Quart exits the app context before iterating the SSE response body, so
|
||||
# current_app cannot be accessed inside event_generator().
|
||||
main_agent = get_agent_for(user)
|
||||
await _prepare_conversation_summary(main_agent, payload, agent_config)
|
||||
|
||||
async def event_generator():
|
||||
final_message = None
|
||||
@@ -450,15 +408,16 @@ async def stream_query():
|
||||
content_chunks = 0
|
||||
model_name: str | None = None
|
||||
cancelled = False
|
||||
persistence_task: asyncio.Task | None = None
|
||||
persist_attempted = False
|
||||
|
||||
async def persist(text: str) -> None:
|
||||
await add_message_to_conversation(
|
||||
conversation=conversation,
|
||||
message=text,
|
||||
speaker="simba",
|
||||
user=user,
|
||||
)
|
||||
# At most one attempt: a save cancelled mid-flight must not be retried
|
||||
# by the cancellation handler, or the answer could be stored twice.
|
||||
nonlocal persist_attempted
|
||||
if persist_attempted:
|
||||
return
|
||||
persist_attempted = True
|
||||
await save_answer(text)
|
||||
|
||||
def _log_summary(*, error: str | None = None) -> None:
|
||||
now = time.monotonic()
|
||||
@@ -502,9 +461,7 @@ async def stream_query():
|
||||
)
|
||||
|
||||
try:
|
||||
aiter = main_agent.astream_events(
|
||||
payload, version="v2", config=agent_config
|
||||
).__aiter__()
|
||||
aiter = runtime.stream_prepared(turn).__aiter__()
|
||||
while True:
|
||||
# Only a silent model call counts as a stall. A model that is
|
||||
# thinking (or streaming empty chunks) keeps resetting the clock,
|
||||
@@ -652,9 +609,8 @@ async def stream_query():
|
||||
|
||||
final_message = final_message or streamed
|
||||
if final_message:
|
||||
# Shield the single persistence task across client disconnects.
|
||||
persistence_task = _persist_in_background(persist(final_message))
|
||||
await asyncio.shield(persistence_task)
|
||||
# The durable worker owns persistence independently of subscribers.
|
||||
await persist(final_message)
|
||||
yield f"data: {json.dumps({'type': 'response', 'message': final_message})}\n\n"
|
||||
else:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': 'No response generated'})}\n\n"
|
||||
@@ -664,9 +620,9 @@ async def stream_query():
|
||||
count_result(stream=True, result="cancelled")
|
||||
observe_response(stream=True, seconds=time.monotonic() - t0)
|
||||
_log_summary()
|
||||
text = final_message or streamed
|
||||
if text and persistence_task is None:
|
||||
_persist_in_background(persist(text))
|
||||
text = final_message or (streamed + (tag_buf if not in_thinking else ""))
|
||||
if text:
|
||||
await persist(text)
|
||||
raise
|
||||
except Exception as e:
|
||||
count_result(stream=True, result="error")
|
||||
@@ -680,19 +636,8 @@ async def stream_query():
|
||||
_log_summary(error=None if final_message else "No response generated")
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
response = await make_response(
|
||||
event_generator(),
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
# Quart's default 60s response deadline cancels even active SSE streams.
|
||||
# Silent model calls are bounded separately by MODEL_STALL_TIMEOUT.
|
||||
response.timeout = None
|
||||
return response
|
||||
async for frame in event_generator():
|
||||
yield frame
|
||||
|
||||
|
||||
@conversation_blueprint.route("/<conversation_id>")
|
||||
|
||||
@@ -140,17 +140,28 @@ async def web_search(query: str) -> str:
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def simba_search(query: str):
|
||||
async def simba_search(
|
||||
query: str, start_date: str = "", end_date: str = "", k: int = 8
|
||||
):
|
||||
"""Search Simba's medical records, vet documents, and household documents.
|
||||
|
||||
Args:
|
||||
query: Question or information need
|
||||
start_date: Inclusive document date YYYY-MM-DD, or empty for all dates.
|
||||
end_date: Inclusive document date YYYY-MM-DD, or empty for all dates.
|
||||
k: Maximum results, from 1 to 20.
|
||||
"""
|
||||
print(f"[SIMBA SEARCH] Tool called with query: {query}")
|
||||
serialized, docs = await query_vector_store(query=query, source="paperless")
|
||||
print(f"[SIMBA SEARCH] Found {len(docs)} documents")
|
||||
print(f"[SIMBA SEARCH] Serialized result length: {len(serialized)}")
|
||||
print(f"[SIMBA SEARCH] First 200 chars: {serialized[:200]}")
|
||||
try:
|
||||
kwargs = (
|
||||
{"start_date": start_date, "end_date": end_date, "k": k}
|
||||
if start_date or end_date or k != 8
|
||||
else {}
|
||||
)
|
||||
serialized, docs = await query_vector_store(
|
||||
query=query, source="paperless", **kwargs
|
||||
)
|
||||
except ValueError as exc:
|
||||
return str(exc), None
|
||||
return serialized, search_documents_artifact(query, "paperless", docs)
|
||||
|
||||
|
||||
@@ -453,18 +464,30 @@ def _note_artifact(path: str, parsed: dict, title: str | None = None) -> dict:
|
||||
|
||||
|
||||
@tool(response_format="content_and_artifact")
|
||||
async def obsidian_search_notes(query: str):
|
||||
async def obsidian_search_notes(
|
||||
query: str, start_date: str = "", end_date: str = "", k: int = 8
|
||||
):
|
||||
"""Search personal Obsidian vault notes.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
start_date: Inclusive note date YYYY-MM-DD, or empty for all dates.
|
||||
end_date: Inclusive note date YYYY-MM-DD, or empty for all dates.
|
||||
k: Maximum results, from 1 to 20.
|
||||
"""
|
||||
if not obsidian_enabled:
|
||||
return OBSIDIAN_NOT_CONFIGURED, None
|
||||
|
||||
try:
|
||||
# Query vector store filtered to obsidian source only
|
||||
serialized, docs = await query_vector_store(query=query, source="obsidian")
|
||||
kwargs = (
|
||||
{"start_date": start_date, "end_date": end_date, "k": k}
|
||||
if start_date or end_date or k != 8
|
||||
else {}
|
||||
)
|
||||
serialized, docs = await query_vector_store(
|
||||
query=query, source="obsidian", **kwargs
|
||||
)
|
||||
return serialized, search_documents_artifact(query, "obsidian", docs)
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -148,9 +148,16 @@ def search_documents_artifact(query: str, source: str, docs) -> dict | None:
|
||||
if not docs:
|
||||
return None
|
||||
results = []
|
||||
from blueprints.rag.retrieval import citation_url
|
||||
|
||||
for doc in docs:
|
||||
meta = doc.metadata or {}
|
||||
result = {"snippet": snippet(doc.page_content)}
|
||||
if url := citation_url(doc):
|
||||
result["url"] = url
|
||||
result["source_id"] = str(doc.id)
|
||||
if headings := [meta[k] for k in ("h1", "h2", "h3") if meta.get(k)]:
|
||||
result["headings"] = headings
|
||||
if source == "obsidian":
|
||||
filepath = meta.get("filepath") or ""
|
||||
path = vault_relative_path(filepath) if filepath else ""
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
"""Owner-scoped history and memory management; register history_blueprint in app."""
|
||||
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
from quart import Blueprint, Response, jsonify, request
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from blueprints.users.decorators import jwt_access_required
|
||||
from .models import Conversation, ConversationMessage, Speaker, UserMemory
|
||||
|
||||
history_blueprint = Blueprint("history_api", __name__, url_prefix="/api/history")
|
||||
|
||||
|
||||
def valid_id(value):
|
||||
try:
|
||||
return UUID(str(value))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def pagination(limit=20, offset=0):
|
||||
try:
|
||||
limit, offset = int(limit), int(offset)
|
||||
except (TypeError, ValueError):
|
||||
raise ValueError("limit and offset must be integers") from None
|
||||
if not 1 <= limit <= 100 or offset < 0:
|
||||
raise ValueError("limit must be 1–100 and offset must be nonnegative")
|
||||
return limit, offset
|
||||
|
||||
|
||||
def display_text(message):
|
||||
return (
|
||||
message.text.split("\n", 1)[0]
|
||||
if message.speaker == Speaker.TOOL
|
||||
else message.text
|
||||
)
|
||||
|
||||
|
||||
def snippet(text, search):
|
||||
index = text.casefold().find(search.casefold())
|
||||
start = max(0, index - 60)
|
||||
end = min(len(text), start + 200)
|
||||
return ("…" if start else "") + text[start:end] + ("…" if end < len(text) else "")
|
||||
|
||||
|
||||
async def list_owned_conversations(user_id, search=None, limit=20, offset=0):
|
||||
"""Stable pinned-first pagination. DISTINCT runs before LIMIT on message joins."""
|
||||
limit, offset = pagination(limit, offset)
|
||||
if search is not None and (not isinstance(search, str) or len(search) > 500):
|
||||
raise ValueError("search must be text of at most 500 characters")
|
||||
search = (search or "").strip()
|
||||
query = Conversation.filter(user_id=user_id)
|
||||
if search:
|
||||
query = query.filter(
|
||||
Q(name__icontains=search) | Q(messages__text__icontains=search)
|
||||
)
|
||||
conversations = (
|
||||
await query.distinct()
|
||||
.order_by("-pinned", "-updated_at", "-id")
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
rows = []
|
||||
for conversation in conversations:
|
||||
row = {
|
||||
"id": str(conversation.id),
|
||||
"name": conversation.name,
|
||||
"pinned": conversation.pinned,
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
}
|
||||
if search:
|
||||
match = (
|
||||
await ConversationMessage.filter(
|
||||
conversation_id=conversation.id, text__icontains=search
|
||||
)
|
||||
.order_by("created_at", "id")
|
||||
.first()
|
||||
)
|
||||
row["snippet"] = (
|
||||
snippet(display_text(match), search)
|
||||
if match
|
||||
else snippet(conversation.name, search)
|
||||
)
|
||||
if (
|
||||
match
|
||||
and match.speaker == Speaker.TOOL
|
||||
and search.casefold() not in display_text(match).casefold()
|
||||
):
|
||||
row["snippet"] = (
|
||||
"Match in stored tool context (hidden in chat): "
|
||||
+ display_text(match)[:150]
|
||||
)
|
||||
row["match_message_id"] = str(match.id) if match else None
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@history_blueprint.get("/conversations")
|
||||
@jwt_access_required
|
||||
async def conversations():
|
||||
try:
|
||||
rows = await list_owned_conversations(
|
||||
get_jwt_identity(),
|
||||
request.args.get("search"),
|
||||
request.args.get("limit", 20),
|
||||
request.args.get("offset", 0),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
return jsonify(rows)
|
||||
|
||||
|
||||
async def owned_conversation(conversation_id):
|
||||
identifier = valid_id(conversation_id)
|
||||
if identifier is None:
|
||||
return None
|
||||
return await Conversation.get_or_none(id=identifier, user_id=get_jwt_identity())
|
||||
|
||||
|
||||
@history_blueprint.patch("/conversations/<conversation_id>/pin")
|
||||
@jwt_access_required
|
||||
async def pin(conversation_id):
|
||||
conversation = await owned_conversation(conversation_id)
|
||||
if conversation is None:
|
||||
return jsonify(error="conversation not found"), 404
|
||||
data = await request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or type(data.get("pinned")) is not bool:
|
||||
return jsonify(error="pinned must be a boolean"), 400
|
||||
conversation.pinned = data["pinned"]
|
||||
# Pinning must not change conversation recency.
|
||||
await conversation.save(update_fields=["pinned"])
|
||||
return jsonify(id=str(conversation.id), pinned=conversation.pinned)
|
||||
|
||||
|
||||
async def export_owned_conversation(conversation):
|
||||
messages = await ConversationMessage.filter(
|
||||
conversation_id=conversation.id
|
||||
).order_by("created_at", "id")
|
||||
exported = []
|
||||
for message in messages:
|
||||
text = display_text(message)
|
||||
attachments = [
|
||||
{"kind": "file", "recorded_name": name, "contents_included": False}
|
||||
for name in re.findall(r"^📎 (.+)$", text, re.MULTILINE)
|
||||
]
|
||||
if message.image_key:
|
||||
# Stored references can be malformed or point outside this owner; never resolve or export keys.
|
||||
attachments.append(
|
||||
{
|
||||
"kind": "image",
|
||||
"stored_reference_present": True,
|
||||
"contents_included": False,
|
||||
}
|
||||
)
|
||||
exported.append(
|
||||
{
|
||||
"id": str(message.id),
|
||||
"speaker": message.speaker.value,
|
||||
"created_at": message.created_at.isoformat(),
|
||||
"text": text,
|
||||
"provenance": (
|
||||
"displayed_tool_header"
|
||||
if message.speaker == Speaker.TOOL
|
||||
else "stored_message_text"
|
||||
),
|
||||
"raw_tool_context_included": False,
|
||||
"attachments": attachments,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"version": 1,
|
||||
"id": str(conversation.id),
|
||||
"name": conversation.name,
|
||||
"pinned": conversation.pinned,
|
||||
"created_at": conversation.created_at.isoformat(),
|
||||
"updated_at": conversation.updated_at.isoformat(),
|
||||
"messages": exported,
|
||||
"export_note": "Stored display text only. Raw tool context, artifacts, attachment contents and storage references are omitted. File names are recorded labels, not verified files.",
|
||||
}
|
||||
|
||||
|
||||
@history_blueprint.get("/conversations/<conversation_id>/export")
|
||||
@jwt_access_required
|
||||
async def export(conversation_id):
|
||||
conversation = await owned_conversation(conversation_id)
|
||||
if conversation is None:
|
||||
return jsonify(error="conversation not found"), 404
|
||||
format_name = request.args.get("format", "json")
|
||||
if format_name not in ("json", "markdown"):
|
||||
return jsonify(error="format must be json or markdown"), 400
|
||||
data = await export_owned_conversation(conversation)
|
||||
if format_name == "json":
|
||||
response = jsonify(data)
|
||||
extension = "json"
|
||||
else:
|
||||
parts = [f"# {data['name']}", data["export_note"]]
|
||||
for message in data["messages"]:
|
||||
parts.extend(
|
||||
[
|
||||
f"## {message['speaker']} — {message['created_at']}",
|
||||
message["text"],
|
||||
f"Provenance: {message['provenance']}.",
|
||||
]
|
||||
)
|
||||
if message["attachments"]:
|
||||
parts.append(
|
||||
"Attachment contents omitted; recorded labels appear in the stored text."
|
||||
)
|
||||
response = Response(
|
||||
"\n\n".join(parts), content_type="text/markdown; charset=utf-8"
|
||||
)
|
||||
extension = "md"
|
||||
response.headers["Content-Disposition"] = (
|
||||
f'attachment; filename="conversation-{conversation.id}.{extension}"'
|
||||
)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
|
||||
|
||||
def memory_row(memory):
|
||||
return {
|
||||
"id": str(memory.id),
|
||||
"content": memory.content,
|
||||
"created_at": memory.created_at.isoformat(),
|
||||
"updated_at": memory.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@history_blueprint.get("/memories")
|
||||
@jwt_access_required
|
||||
async def memories():
|
||||
try:
|
||||
limit, offset = pagination(
|
||||
request.args.get("limit", 100), request.args.get("offset", 0)
|
||||
)
|
||||
except ValueError as exc:
|
||||
return jsonify(error=str(exc)), 400
|
||||
rows = (
|
||||
await UserMemory.filter(user_id=get_jwt_identity())
|
||||
.order_by("-updated_at", "-id")
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
)
|
||||
return jsonify([memory_row(row) for row in rows])
|
||||
|
||||
|
||||
@history_blueprint.route("/memories/<memory_id>", methods=["PATCH", "DELETE"])
|
||||
@jwt_access_required
|
||||
async def change_memory(memory_id):
|
||||
identifier = valid_id(memory_id)
|
||||
memory = (
|
||||
await UserMemory.get_or_none(id=identifier, user_id=get_jwt_identity())
|
||||
if identifier
|
||||
else None
|
||||
)
|
||||
if memory is None:
|
||||
return jsonify(error="memory not found"), 404
|
||||
if request.method == "DELETE":
|
||||
await memory.delete()
|
||||
return "", 204
|
||||
data = await request.get_json(silent=True)
|
||||
if (
|
||||
not isinstance(data, dict)
|
||||
or not isinstance(data.get("content"), str)
|
||||
or not data["content"].strip()
|
||||
or len(data["content"]) > 10000
|
||||
):
|
||||
return (
|
||||
jsonify(error="content must be nonempty text of at most 10000 characters"),
|
||||
400,
|
||||
)
|
||||
memory.content = data["content"].strip()
|
||||
await memory.save(update_fields=["content", "updated_at"])
|
||||
return jsonify(memory_row(memory))
|
||||
@@ -23,6 +23,7 @@ class Conversation(Model):
|
||||
"models.User", related_name="conversations", null=True
|
||||
)
|
||||
channel = fields.CharField(max_length=20, default="web", null=True)
|
||||
pinned = fields.BooleanField(default=False)
|
||||
|
||||
class Meta:
|
||||
table = "conversations"
|
||||
|
||||
@@ -32,6 +32,8 @@ Upcoming Appointments:
|
||||
|
||||
IMPORTANT: When users ask factual questions about Simba's health, medical history, veterinary visits, medications, weight, or any information that would be in documents, you MUST use the simba_search tool to retrieve accurate information before answering. Do not rely on general knowledge - always search the documents for factual questions.
|
||||
|
||||
SOURCE CITATIONS: When using retrieved documents, cite the provided Citation URLs with Markdown links. Use only URLs returned by tools; never invent source IDs or references. A citation opens the exact indexed section, which may differ from a newer original. Document date filters refer to indexed document metadata, not necessarily the date of every event described in the document.
|
||||
|
||||
BUDGET & FINANCE (YNAB Integration):
|
||||
You have access to Ryan's budget data through YNAB (You Need A Budget). When users ask about financial matters, use the appropriate YNAB tools:
|
||||
- Use ynab_budget_summary for a month's overview (Ready to Assign, income, spent, overspending, net worth)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Durable turn ownership and append-only replay log (shared PostgreSQL/SQLite)."""
|
||||
|
||||
from tortoise import fields
|
||||
from tortoise.models import Model
|
||||
|
||||
|
||||
class ChatRun(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
conversation = fields.ForeignKeyField(
|
||||
"models.Conversation", related_name="chat_runs"
|
||||
)
|
||||
owner = fields.ForeignKeyField("models.User", related_name="chat_runs")
|
||||
request_key = fields.CharField(max_length=128)
|
||||
payload = fields.JSONField()
|
||||
# Unique nullable slot: only one generation can write a conversation checkpoint.
|
||||
active_key = fields.CharField(max_length=36, null=True, unique=True)
|
||||
status = fields.CharField(max_length=20, default="running")
|
||||
worker_token = fields.UUIDField()
|
||||
lease_until = fields.DatetimeField(index=True)
|
||||
cancel_requested = fields.BooleanField(default=False)
|
||||
sequence = fields.IntField(default=0)
|
||||
partial = fields.TextField(default="")
|
||||
user_saved = fields.BooleanField(default=False)
|
||||
answer_saved = fields.BooleanField(default=False)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "chat_runs"
|
||||
unique_together = (("owner", "conversation", "request_key"),)
|
||||
|
||||
|
||||
class ChatRunEvent(Model):
|
||||
id = fields.BigIntField(primary_key=True)
|
||||
run = fields.ForeignKeyField("models.ChatRun", related_name="events")
|
||||
sequence = fields.IntField()
|
||||
payload = fields.JSONField()
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
table = "chat_run_events"
|
||||
unique_together = (("run", "sequence"),)
|
||||
@@ -0,0 +1,576 @@
|
||||
"""A browser connection is only a subscriber, never the owner of generation."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
import weakref
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from contextlib import aclosing
|
||||
from contextvars import Context
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, make_response, request
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
from tortoise.exceptions import IntegrityError
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from blueprints.users.decorators import jwt_access_required
|
||||
from blueprints.users.models import User
|
||||
from .models import Conversation
|
||||
from .logic import add_message_to_conversation
|
||||
from . import runtime
|
||||
from .run_models import ChatRun, ChatRunEvent
|
||||
|
||||
runs_blueprint = Blueprint("chat_runs", __name__, url_prefix="/api/conversation")
|
||||
LEASE_SECONDS = max(10, float(os.getenv("CHAT_RUN_LEASE_SECONDS", "30")))
|
||||
MAX_SECONDS = max(1, float(os.getenv("CHAT_RUN_MAX_SECONDS", "1800")))
|
||||
_tasks = {} # strong refs, keyed by run id; DB is the authority across processes
|
||||
# Same-process subscribers wait on these instead of polling; other processes
|
||||
# still see new events through the polling fallback.
|
||||
# Weak values: an entry lives only while some subscriber is waiting on it.
|
||||
_wakeups: weakref.WeakValueDictionary[str, asyncio.Event] = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
# Streamed deltas are coalesced so each write carries ~this much text.
|
||||
FLUSH_SECONDS = 0.15
|
||||
POLL_SECONDS = 0.5
|
||||
_DELTA_TYPES = ("content", "thinking_delta")
|
||||
|
||||
|
||||
def _notify(run_id):
|
||||
event = _wakeups.pop(str(run_id), None)
|
||||
if event is not None:
|
||||
event.set()
|
||||
|
||||
|
||||
def _waiter(run_id):
|
||||
event = _wakeups.get(str(run_id))
|
||||
if event is None:
|
||||
event = _wakeups[str(run_id)] = asyncio.Event()
|
||||
return event
|
||||
|
||||
|
||||
_draining_apps = set()
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class RunConflict(Exception):
|
||||
def __init__(self, message, status=409):
|
||||
super().__init__(message)
|
||||
self.status = status
|
||||
|
||||
|
||||
async def owned_run(owner, conversation_id, run_id):
|
||||
try:
|
||||
return await ChatRun.get_or_none(
|
||||
id=run_id,
|
||||
owner_id=owner,
|
||||
conversation_id=conversation_id,
|
||||
conversation__user_id=owner,
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
async def _locked(run_id, db):
|
||||
# UPDATE takes a row lock on PostgreSQL and a write lock on SQLite, before reads.
|
||||
await ChatRun.filter(id=run_id).using_db(db).update(updated_at=now())
|
||||
return await ChatRun.get_or_none(id=run_id).using_db(db)
|
||||
|
||||
|
||||
async def _save_answer(run, text):
|
||||
if text and not run.answer_saved:
|
||||
conversation = await Conversation.get(id=run.conversation_id)
|
||||
user = await User.get(id=run.owner_id)
|
||||
await add_message_to_conversation(conversation, text, "simba", user)
|
||||
run.answer_saved = True
|
||||
|
||||
|
||||
async def append(run_id, token, payload):
|
||||
async with in_transaction() as db:
|
||||
run = await _locked(run_id, db)
|
||||
if (
|
||||
run.status != "running"
|
||||
or run.cancel_requested
|
||||
or str(run.worker_token) != str(token)
|
||||
):
|
||||
raise asyncio.CancelledError()
|
||||
run.sequence += 1
|
||||
if payload.get("type") == "content":
|
||||
run.partial += payload.get("delta", "")
|
||||
await ChatRunEvent.create(
|
||||
run_id=run.id, sequence=run.sequence, payload=payload, using_db=db
|
||||
)
|
||||
await run.save(using_db=db)
|
||||
_notify(run_id)
|
||||
|
||||
|
||||
async def finish(run_id, status, payload=None, token=None, tail=""):
|
||||
"""Terminalize a run; `tail` is buffered content never appended as an event."""
|
||||
async with in_transaction() as db:
|
||||
run = await _locked(run_id, db)
|
||||
if (
|
||||
run is None
|
||||
or run.status != "running"
|
||||
or (token and str(run.worker_token) != str(token))
|
||||
):
|
||||
return
|
||||
run.partial += tail
|
||||
await _save_answer(run, run.partial)
|
||||
if run.cancel_requested and status == "completed":
|
||||
status = "cancelled"
|
||||
run.status = status
|
||||
run.active_key = None
|
||||
for event in ([payload] if payload else []) + [
|
||||
{"type": "done", "status": status}
|
||||
]:
|
||||
run.sequence += 1
|
||||
await ChatRunEvent.create(
|
||||
run_id=run.id, sequence=run.sequence, payload=event, using_db=db
|
||||
)
|
||||
await run.save(using_db=db)
|
||||
_notify(run_id)
|
||||
|
||||
|
||||
async def expire_runs(conversation_id=None):
|
||||
query = ChatRun.filter(status="running", lease_until__lt=now())
|
||||
if conversation_id:
|
||||
query = query.filter(conversation_id=conversation_id)
|
||||
for candidate in await query:
|
||||
# Recheck under lock; a heartbeat may have arrived after the initial scan.
|
||||
async with in_transaction() as db:
|
||||
run = await _locked(candidate.id, db)
|
||||
if run is None or run.status != "running" or run.lease_until >= now():
|
||||
continue
|
||||
await finish(
|
||||
run.id,
|
||||
"cancelled" if run.cancel_requested else "interrupted",
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Generation was interrupted by a server restart. Please retry.",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def create_run(app, owner, data):
|
||||
if not isinstance(data, dict):
|
||||
raise RunConflict("JSON object required", 400)
|
||||
conversation_id = data.get("conversation_id")
|
||||
try:
|
||||
conversation = await Conversation.get_or_none(id=conversation_id, user_id=owner)
|
||||
except (ValueError, TypeError):
|
||||
conversation = None
|
||||
if conversation is None:
|
||||
raise RunConflict("conversation not found", 404)
|
||||
key = data.get("request_id", str(uuid.uuid4()))
|
||||
if not isinstance(key, str) or not key or len(key) > 128:
|
||||
raise RunConflict("request_id must be 1–128 characters", 400)
|
||||
payload = {k: data.get(k) for k in ("query", "image_key", "file_key")}
|
||||
if not isinstance(payload["query"], str):
|
||||
raise RunConflict("query must be a string", 400)
|
||||
for name, prefix in (("image_key", ""), ("file_key", "files/")):
|
||||
value = payload[name]
|
||||
# Matches the worker's checks, so a run is never created only to fail.
|
||||
if value and (
|
||||
not isinstance(value, str)
|
||||
or ".." in value
|
||||
or not value.startswith(f"conversations/{conversation.id}/{prefix}")
|
||||
):
|
||||
raise RunConflict(f"invalid {name}", 400)
|
||||
await expire_runs(str(conversation.id))
|
||||
|
||||
async def existing():
|
||||
run = await ChatRun.get_or_none(
|
||||
owner_id=owner, conversation_id=conversation.id, request_key=key
|
||||
)
|
||||
if run and run.payload != payload:
|
||||
raise RunConflict("request_id already used with different input")
|
||||
return run
|
||||
|
||||
run = await existing()
|
||||
if run:
|
||||
return run, False
|
||||
if id(app) in _draining_apps:
|
||||
raise RunConflict("Server is draining; retry creation shortly", 503)
|
||||
token = uuid.uuid4()
|
||||
try:
|
||||
run = await ChatRun.create(
|
||||
owner_id=owner,
|
||||
conversation_id=conversation.id,
|
||||
request_key=key,
|
||||
payload=payload,
|
||||
active_key=str(conversation.id),
|
||||
worker_token=token,
|
||||
lease_until=now() + timedelta(seconds=LEASE_SECONDS),
|
||||
)
|
||||
except IntegrityError:
|
||||
run = await existing()
|
||||
if run:
|
||||
return run, False
|
||||
raise RunConflict("A run is already active in this conversation")
|
||||
task = asyncio.create_task(_work(app, run.id, token), context=Context())
|
||||
_tasks[str(run.id)] = (app, task)
|
||||
|
||||
def completed(task):
|
||||
_tasks.pop(str(run.id), None)
|
||||
if not task.cancelled() and task.exception():
|
||||
logging.error(
|
||||
"Chat run worker exited before finalization", exc_info=task.exception()
|
||||
)
|
||||
|
||||
task.add_done_callback(completed)
|
||||
return run, True
|
||||
|
||||
|
||||
class _DeltaBatch:
|
||||
"""Coalesce streamed deltas so a run writes ~FLUSH_SECONDS of text per event."""
|
||||
|
||||
def __init__(self, run_id, token):
|
||||
self.run_id, self.token = run_id, token
|
||||
self.frames: asyncio.Queue = asyncio.Queue() # raw SSE frames not yet handled
|
||||
self.pending = None
|
||||
self.last_flush = 0.0
|
||||
|
||||
def due(self):
|
||||
"""Seconds until the pending delta must be written; None if nothing waits."""
|
||||
if self.pending is None:
|
||||
return None
|
||||
return max(0.0, FLUSH_SECONDS - (time.monotonic() - self.last_flush))
|
||||
|
||||
def unsent_content(self):
|
||||
"""Answer text that was produced but never written as an event."""
|
||||
parts = []
|
||||
if self.pending is not None and self.pending["type"] == "content":
|
||||
parts.append(self.pending["delta"])
|
||||
while not self.frames.empty():
|
||||
item = self.frames.get_nowait()
|
||||
if isinstance(item, str) and item.startswith("data: {"):
|
||||
payload = json.loads(item.removeprefix("data: "))
|
||||
if payload.get("type") == "content":
|
||||
parts.append(payload.get("delta", ""))
|
||||
return "".join(parts)
|
||||
|
||||
async def flush(self):
|
||||
if self.pending is not None:
|
||||
await append(self.run_id, self.token, self.pending)
|
||||
self.pending = None
|
||||
self.last_flush = time.monotonic()
|
||||
|
||||
async def add(self, payload):
|
||||
if payload.get("type") not in _DELTA_TYPES:
|
||||
await self.flush()
|
||||
await append(self.run_id, self.token, payload)
|
||||
elif self.pending is not None and self.pending["type"] == payload["type"]:
|
||||
self.pending["delta"] += payload.get("delta", "")
|
||||
else:
|
||||
await self.flush()
|
||||
self.pending = {**payload, "delta": payload.get("delta", "")}
|
||||
|
||||
|
||||
async def _heartbeat(run_id, token, worker):
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(min(1, LEASE_SECONDS / 3))
|
||||
changed = await ChatRun.filter(
|
||||
id=run_id,
|
||||
worker_token=token,
|
||||
status="running",
|
||||
cancel_requested=False,
|
||||
lease_until__gte=now(),
|
||||
).update(lease_until=now() + timedelta(seconds=LEASE_SECONDS))
|
||||
if not changed:
|
||||
worker.cancel()
|
||||
return
|
||||
except Exception:
|
||||
logging.exception("Chat run heartbeat failed")
|
||||
worker.cancel()
|
||||
|
||||
|
||||
async def _work(app, run_id, token):
|
||||
heartbeat = asyncio.create_task(_heartbeat(run_id, token, asyncio.current_task()))
|
||||
batch = _DeltaBatch(run_id, token)
|
||||
async with app.app_context():
|
||||
try:
|
||||
async with asyncio.timeout(MAX_SECONDS):
|
||||
from . import _chat_run_stream
|
||||
|
||||
run = await ChatRun.get(id=run_id)
|
||||
conversation = await Conversation.get(id=run.conversation_id)
|
||||
user = await User.get(id=run.owner_id)
|
||||
|
||||
async def save_user(text, image_key):
|
||||
async with in_transaction() as db:
|
||||
locked = await _locked(run_id, db)
|
||||
if (
|
||||
locked is None
|
||||
or locked.status != "running"
|
||||
or locked.cancel_requested
|
||||
):
|
||||
raise asyncio.CancelledError()
|
||||
if not locked.user_saved:
|
||||
await add_message_to_conversation(
|
||||
conversation, text, "user", user, image_key=image_key
|
||||
)
|
||||
locked.user_saved = True
|
||||
await locked.save(using_db=db)
|
||||
|
||||
async def save_answer(text):
|
||||
async with in_transaction() as db:
|
||||
locked = await _locked(run_id, db)
|
||||
if locked is not None and locked.status == "running":
|
||||
await _save_answer(locked, text)
|
||||
await locked.save(using_db=db)
|
||||
|
||||
failed = False
|
||||
async with (
|
||||
runtime.thread_lock(str(conversation.id)),
|
||||
aclosing(
|
||||
_chat_run_stream(
|
||||
{**run.payload, "conversation_id": str(conversation.id)},
|
||||
user,
|
||||
conversation,
|
||||
save_user,
|
||||
save_answer,
|
||||
)
|
||||
) as stream,
|
||||
):
|
||||
# The generator runs in one task (keeping its context across
|
||||
# steps) so buffered text can be flushed on a timer even while
|
||||
# the model emits nothing, e.g. while writing tool arguments.
|
||||
frames = batch.frames
|
||||
|
||||
async def produce():
|
||||
try:
|
||||
async for frame in stream:
|
||||
frames.put_nowait(frame)
|
||||
frames.put_nowait(None)
|
||||
except BaseException as exc:
|
||||
frames.put_nowait(exc)
|
||||
raise
|
||||
|
||||
producer = asyncio.create_task(produce())
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
item = await asyncio.wait_for(frames.get(), batch.due())
|
||||
except TimeoutError:
|
||||
await batch.flush()
|
||||
continue
|
||||
if item is None:
|
||||
break
|
||||
if isinstance(item, BaseException):
|
||||
raise item
|
||||
raw = item.removeprefix("data: ").strip()
|
||||
if raw == "[DONE]":
|
||||
continue
|
||||
payload = json.loads(raw)
|
||||
failed |= payload.get("type") == "error"
|
||||
await batch.add(payload)
|
||||
await batch.flush()
|
||||
finally:
|
||||
producer.cancel()
|
||||
await asyncio.gather(producer, return_exceptions=True)
|
||||
await finish(run_id, "failed" if failed else "completed", token=token)
|
||||
except asyncio.CancelledError:
|
||||
run = await ChatRun.get_or_none(id=run_id)
|
||||
if run is None:
|
||||
return
|
||||
status = "cancelled" if run.cancel_requested else "interrupted"
|
||||
await finish(
|
||||
run_id,
|
||||
status,
|
||||
{
|
||||
"type": "error",
|
||||
"message": (
|
||||
"Generation stopped."
|
||||
if status == "cancelled"
|
||||
else "Generation interrupted. Please retry."
|
||||
),
|
||||
},
|
||||
token=token,
|
||||
tail=batch.unsent_content(),
|
||||
)
|
||||
except Exception:
|
||||
logging.exception("Chat generation failed")
|
||||
await finish(
|
||||
run_id,
|
||||
"failed",
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Generation failed or exceeded its time limit. Please retry.",
|
||||
},
|
||||
token=token,
|
||||
)
|
||||
finally:
|
||||
heartbeat.cancel()
|
||||
await asyncio.gather(heartbeat, return_exceptions=True)
|
||||
|
||||
|
||||
async def replay_response(run, cursor):
|
||||
async def events():
|
||||
nonlocal cursor
|
||||
started = time.monotonic()
|
||||
last_expiry = 0.0
|
||||
while time.monotonic() - started < MAX_SECONDS + LEASE_SECONDS:
|
||||
waiter = _waiter(run.id) # taken before reading, so no wakeup is lost
|
||||
if time.monotonic() - last_expiry >= POLL_SECONDS:
|
||||
await expire_runs(str(run.conversation_id))
|
||||
last_expiry = time.monotonic()
|
||||
batch = (
|
||||
await ChatRunEvent.filter(run_id=run.id, sequence__gt=cursor)
|
||||
.order_by("sequence")
|
||||
.limit(200)
|
||||
)
|
||||
for event in batch:
|
||||
cursor = event.sequence
|
||||
if event.payload.get("type") == "done":
|
||||
yield f"id: {cursor}\ndata: [DONE]\n\n"
|
||||
return
|
||||
yield f"id: {cursor}\ndata: {json.dumps(event.payload)}\n\n"
|
||||
latest = await ChatRun.get_or_none(id=run.id)
|
||||
if latest is None or (
|
||||
latest.status != "running" and cursor >= latest.sequence
|
||||
):
|
||||
yield "data: [DONE]\n\n"
|
||||
return
|
||||
if not batch:
|
||||
yield ": keepalive\n\n"
|
||||
try:
|
||||
await asyncio.wait_for(waiter.wait(), POLL_SECONDS)
|
||||
except TimeoutError:
|
||||
pass
|
||||
# Finite subscriber lifetime; client reattaches with its last cursor.
|
||||
|
||||
response = await make_response(
|
||||
events(),
|
||||
200,
|
||||
{
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
"X-Accel-Buffering": "no",
|
||||
"X-Chat-Run-ID": str(run.id),
|
||||
},
|
||||
)
|
||||
response.timeout = None
|
||||
return response
|
||||
|
||||
|
||||
def serialize(run):
|
||||
return {
|
||||
"id": str(run.id),
|
||||
"conversation_id": str(run.conversation_id),
|
||||
"status": run.status,
|
||||
"cursor": run.sequence,
|
||||
"created_at": run.created_at.isoformat(),
|
||||
"query": run.payload.get("query", ""),
|
||||
"image_key": run.payload.get("image_key"),
|
||||
}
|
||||
|
||||
|
||||
@runs_blueprint.post("/runs")
|
||||
@jwt_access_required
|
||||
async def post_run():
|
||||
try:
|
||||
run, created = await create_run(
|
||||
current_app._get_current_object(),
|
||||
get_jwt_identity(),
|
||||
await request.get_json(),
|
||||
)
|
||||
except RunConflict as exc:
|
||||
return jsonify({"error": str(exc)}), exc.status
|
||||
return jsonify(serialize(run)), 201 if created else 200
|
||||
|
||||
|
||||
@runs_blueprint.get("/<conversation_id>/runs")
|
||||
@jwt_access_required
|
||||
async def discover(conversation_id):
|
||||
owner = get_jwt_identity()
|
||||
try:
|
||||
conversation = await Conversation.get_or_none(id=conversation_id, user_id=owner)
|
||||
except ValueError:
|
||||
conversation = None
|
||||
if conversation is None:
|
||||
return jsonify({"error": "conversation not found"}), 404
|
||||
await expire_runs(conversation_id)
|
||||
# Latest terminal run included so restart/interruption can be shown after refresh.
|
||||
run = (
|
||||
await ChatRun.filter(conversation_id=conversation.id, owner_id=owner)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
return jsonify({"run": serialize(run) if run else None})
|
||||
|
||||
|
||||
@runs_blueprint.get("/<conversation_id>/runs/<run_id>/events")
|
||||
@jwt_access_required
|
||||
async def get_events(conversation_id, run_id):
|
||||
run = await owned_run(get_jwt_identity(), conversation_id, run_id)
|
||||
if run is None:
|
||||
return jsonify({"error": "run not found"}), 404
|
||||
try:
|
||||
cursor = int(
|
||||
request.args.get("cursor", request.headers.get("Last-Event-ID", "0"))
|
||||
)
|
||||
if cursor < 0 or cursor > run.sequence:
|
||||
raise ValueError()
|
||||
except ValueError:
|
||||
return jsonify({"error": "invalid cursor"}), 400
|
||||
return await replay_response(run, cursor)
|
||||
|
||||
|
||||
@runs_blueprint.post("/<conversation_id>/runs/<run_id>/cancel")
|
||||
@jwt_access_required
|
||||
async def cancel_run(conversation_id, run_id):
|
||||
run = await owned_run(get_jwt_identity(), conversation_id, run_id)
|
||||
if run is None:
|
||||
return jsonify({"error": "run not found"}), 404
|
||||
await ChatRun.filter(id=run.id, status="running").update(cancel_requested=True)
|
||||
return jsonify(
|
||||
{"status": "cancel_requested" if run.status == "running" else run.status}
|
||||
)
|
||||
|
||||
|
||||
def begin_drain(app):
|
||||
"""Call when ingress/health starts draining, before awaiting run completion."""
|
||||
_draining_apps.add(id(app))
|
||||
|
||||
|
||||
def is_draining(app):
|
||||
return id(app) in _draining_apps
|
||||
|
||||
|
||||
async def drain_runs(app, timeout=None):
|
||||
"""Root calls BEFORE closing agents/checkpointers/database, after stopping ingress."""
|
||||
begin_drain(app)
|
||||
claimed_ids = {
|
||||
run_id for run_id, (task_app, _) in list(_tasks.items()) if task_app is app
|
||||
}
|
||||
tasks = [task for task_app, task in list(_tasks.values()) if task_app is app]
|
||||
if tasks:
|
||||
_, pending = await asyncio.wait(
|
||||
tasks,
|
||||
timeout=(
|
||||
timeout
|
||||
if timeout is not None
|
||||
else float(os.getenv("CHAT_RUN_DRAIN_SECONDS", "1800"))
|
||||
),
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
# Covers a task cancelled before its coroutine first ran.
|
||||
async with app.app_context():
|
||||
for run in await ChatRun.filter(id__in=claimed_ids, status="running"):
|
||||
await finish(
|
||||
run.id,
|
||||
"interrupted",
|
||||
{
|
||||
"type": "error",
|
||||
"message": "Generation interrupted during shutdown. Please retry.",
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Shared agent invocation for authenticated chat and messaging channels."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
import weakref
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
|
||||
from langchain_core.messages import convert_to_messages
|
||||
from quart import current_app
|
||||
|
||||
from metrics import AGENT_REQUESTS_TOTAL, AGENT_RESPONSE_SECONDS
|
||||
from .logic import get_agent_for
|
||||
from .memory import build_system_prompt_with_memories
|
||||
|
||||
|
||||
# Concurrent turns on one checkpoint thread race on read-modify-write of its
|
||||
# history, silently dropping a turn. Hold this around preparation + invocation.
|
||||
_thread_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
weakref.WeakValueDictionary()
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def thread_lock(thread_id: str):
|
||||
lock = _thread_locks.get(thread_id)
|
||||
if lock is None:
|
||||
lock = _thread_locks[thread_id] = asyncio.Lock()
|
||||
async with lock:
|
||||
yield
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreparedTurn:
|
||||
agent: object
|
||||
payload: dict
|
||||
config: dict
|
||||
|
||||
|
||||
async def prepare_summary(agent, payload: dict, config: dict) -> None:
|
||||
store = getattr(current_app, "conversation_summary_store", None)
|
||||
if store is None:
|
||||
return
|
||||
configurable = config["configurable"]
|
||||
try:
|
||||
checkpoint = await agent.aget_state(config)
|
||||
history = convert_to_messages(checkpoint.values.get("messages", []))
|
||||
state = await store.update(
|
||||
configurable["user_id"],
|
||||
configurable["thread_id"],
|
||||
history + convert_to_messages(payload["messages"]),
|
||||
)
|
||||
configurable["conversation_summary"] = state.as_message()
|
||||
except Exception:
|
||||
logging.exception("Failed to prepare conversation summary")
|
||||
|
||||
|
||||
async def prepare_agent_turn(
|
||||
user,
|
||||
query: str,
|
||||
thread_id: str,
|
||||
*,
|
||||
channel: str = "web",
|
||||
messages: list | None = None,
|
||||
instructions: str = "",
|
||||
) -> PreparedTurn:
|
||||
"""Callers resolve and authorize the user/thread before preparing a turn.
|
||||
|
||||
Only authenticated server-side user objects determine permissions. Caller
|
||||
input cannot supply a config that overrides identity or administration.
|
||||
"""
|
||||
if user is None or not getattr(user, "id", None):
|
||||
raise ValueError("An authenticated user is required for agent invocation")
|
||||
if not isinstance(query, str) or not isinstance(thread_id, str) or not thread_id:
|
||||
raise ValueError("query and thread_id must be valid strings")
|
||||
prompt = await build_system_prompt_with_memories(str(user.id), query=query)
|
||||
if instructions:
|
||||
prompt += "\n\n" + instructions
|
||||
config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": bool(user.is_admin()),
|
||||
"thread_id": thread_id,
|
||||
"system_prompt": prompt,
|
||||
"channel": channel,
|
||||
"request_id": str(uuid.uuid4()),
|
||||
}
|
||||
}
|
||||
payload = {
|
||||
"messages": list(messages)
|
||||
if messages is not None
|
||||
else [{"role": "user", "content": query}]
|
||||
}
|
||||
agent = get_agent_for(user)
|
||||
await prepare_summary(agent, payload, config)
|
||||
return PreparedTurn(agent, payload, config)
|
||||
|
||||
|
||||
def _record(turn: PreparedTurn, started: float, result: str) -> None:
|
||||
cfg = turn.config["configurable"]
|
||||
seconds = time.monotonic() - started
|
||||
AGENT_REQUESTS_TOTAL.labels(channel=cfg["channel"], result=result).inc()
|
||||
AGENT_RESPONSE_SECONDS.labels(channel=cfg["channel"]).observe(seconds)
|
||||
logging.info(
|
||||
json.dumps(
|
||||
{
|
||||
"event": "agent_invocation",
|
||||
"request_id": cfg["request_id"],
|
||||
"channel": cfg["channel"],
|
||||
"thread_id": cfg["thread_id"],
|
||||
"user_id": cfg["user_id"],
|
||||
"result": result,
|
||||
"total_ms": round(seconds * 1000),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def invoke_prepared(turn: PreparedTurn) -> dict:
|
||||
started = time.monotonic()
|
||||
result = "success"
|
||||
try:
|
||||
return await turn.agent.ainvoke(turn.payload, config=turn.config)
|
||||
except asyncio.CancelledError:
|
||||
result = "cancelled"
|
||||
raise
|
||||
except Exception:
|
||||
result = "error"
|
||||
raise
|
||||
finally:
|
||||
_record(turn, started, result)
|
||||
|
||||
|
||||
async def invoke_agent_for_user(
|
||||
user,
|
||||
query: str,
|
||||
thread_id: str,
|
||||
*,
|
||||
channel: str = "web",
|
||||
messages: list | None = None,
|
||||
instructions: str = "",
|
||||
) -> dict:
|
||||
async with thread_lock(thread_id):
|
||||
turn = await prepare_agent_turn(
|
||||
user,
|
||||
query,
|
||||
thread_id,
|
||||
channel=channel,
|
||||
messages=messages,
|
||||
instructions=instructions,
|
||||
)
|
||||
return await invoke_prepared(turn)
|
||||
|
||||
|
||||
async def stream_prepared(turn: PreparedTurn):
|
||||
started = time.monotonic()
|
||||
result = "cancelled"
|
||||
try:
|
||||
async for event in turn.agent.astream_events(
|
||||
turn.payload, version="v2", config=turn.config
|
||||
):
|
||||
yield event
|
||||
result = "success"
|
||||
except Exception:
|
||||
result = "error"
|
||||
raise
|
||||
finally:
|
||||
_record(turn, started, result)
|
||||
+73
-41
@@ -1,9 +1,9 @@
|
||||
import hmac
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, current_app, jsonify, request
|
||||
from blueprints.scheduled_messages.validation import json_object
|
||||
from quart import Blueprint, jsonify, request
|
||||
|
||||
from blueprints.users.decorators import admin_required
|
||||
from blueprints.scheduled_messages.models import MessageChannel
|
||||
@@ -23,6 +23,7 @@ def _serialize(job: CronJob) -> dict:
|
||||
"recipient": job.recipient,
|
||||
"channel": job.channel.value,
|
||||
"enabled": job.enabled,
|
||||
"created_by": str(job.created_by_id) if job.created_by_id else None,
|
||||
"last_run_at": job.last_run_at.isoformat() if job.last_run_at else None,
|
||||
"last_status": job.last_status,
|
||||
"last_error": job.last_error,
|
||||
@@ -49,9 +50,12 @@ async def list_jobs():
|
||||
@cron_blueprint.route("/", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_job():
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
data, error = await json_object(
|
||||
("name", "prompt", "recipient", "channel", "description", "created_by"),
|
||||
nullable=("description", "created_by"),
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
|
||||
name = (data.get("name") or "").strip()
|
||||
prompt = (data.get("prompt") or "").strip()
|
||||
@@ -71,7 +75,10 @@ async def create_job():
|
||||
{"error": f"Invalid channel: {channel_str}. Must be 'imessage' or 'email'"}
|
||||
), 400
|
||||
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
|
||||
job = await CronJob.create(
|
||||
created_by_id=get_jwt_identity(),
|
||||
name=name,
|
||||
description=description,
|
||||
prompt=prompt,
|
||||
@@ -89,10 +96,30 @@ async def update_job(job_id: str):
|
||||
if not job:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
data, error = await json_object(
|
||||
("name", "prompt", "recipient", "channel", "description", "created_by"),
|
||||
nullable=("description", "created_by"),
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
|
||||
blank = [
|
||||
f for f in ("name", "prompt", "recipient") if f in data and not data[f].strip()
|
||||
]
|
||||
if blank:
|
||||
return jsonify({"error": f"{', '.join(blank)} cannot be empty"}), 400
|
||||
|
||||
if "created_by" in data:
|
||||
from blueprints.users.models import User
|
||||
|
||||
owner = (
|
||||
await User.get_or_none(id=data["created_by"])
|
||||
if data["created_by"]
|
||||
else None
|
||||
)
|
||||
if not owner:
|
||||
return jsonify({"error": "created_by must identify an existing user"}), 400
|
||||
job.created_by_id = owner.id
|
||||
if "name" in data:
|
||||
job.name = data["name"].strip()
|
||||
if "description" in data:
|
||||
@@ -133,44 +160,49 @@ async def trigger_job(job_id: str):
|
||||
if not job:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if not job.enabled:
|
||||
return jsonify({"skipped": True}), 200
|
||||
data = await request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
run_id = request.headers.get("Idempotency-Key") or data.get("run_id")
|
||||
if not isinstance(run_id, str) or not run_id.strip() or len(run_id) > 255:
|
||||
return jsonify(
|
||||
{"error": "run_id or Idempotency-Key is required (maximum 255 characters)"}
|
||||
), 400
|
||||
from .scheduler import execute_job
|
||||
|
||||
try:
|
||||
import uuid
|
||||
result = await execute_job(job, run_id)
|
||||
return jsonify(result), 200 if result.get("ok") or result.get("skipped") else 409
|
||||
|
||||
response = await current_app.main_agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": job.prompt}]},
|
||||
config={"configurable": {"thread_id": f"cron-{uuid.uuid4()}"}},
|
||||
)
|
||||
result = response.get("messages", [])[-1].content
|
||||
|
||||
if job.channel == MessageChannel.IMESSAGE:
|
||||
from blueprints.imessage import send_imessage
|
||||
from utils.strip_markdown import strip_markdown
|
||||
@cron_blueprint.route("/<job_id>/deliveries", methods=["GET"])
|
||||
@admin_required
|
||||
async def delivery_history(job_id):
|
||||
if not await CronJob.filter(id=job_id).exists():
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
from blueprints.scheduled_messages.delivery import history
|
||||
|
||||
await send_imessage(job.recipient, strip_markdown(result))
|
||||
return jsonify(await history("cron", job_id))
|
||||
|
||||
elif job.channel == MessageChannel.EMAIL:
|
||||
from blueprints.email import send_email_reply
|
||||
|
||||
await send_email_reply(
|
||||
to=job.recipient,
|
||||
subject=job.name,
|
||||
body=result,
|
||||
)
|
||||
@cron_blueprint.route("/<job_id>/retry", methods=["POST"])
|
||||
@admin_required
|
||||
async def retry_job(job_id):
|
||||
job = await CronJob.get_or_none(id=job_id)
|
||||
if not job:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
data = await request.get_json(silent=True)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
run_id = data.get("run_id")
|
||||
if not isinstance(run_id, str) or not run_id or len(run_id) > 255:
|
||||
return jsonify({"error": "run_id is required"}), 400
|
||||
from blueprints.scheduled_messages.models import JobDelivery
|
||||
|
||||
job.last_run_at = datetime.now(timezone.utc)
|
||||
job.last_status = "success"
|
||||
job.last_error = None
|
||||
await job.save()
|
||||
logger.info(f"Cron job {job.id} ({job.name}) executed successfully")
|
||||
return jsonify({"ok": True})
|
||||
if not await JobDelivery.filter(
|
||||
kind="cron", job_id=str(job.id), run_id=run_id, status="failed"
|
||||
).exists():
|
||||
return jsonify({"error": "Only safe failed deliveries can be retried"}), 409
|
||||
from .scheduler import execute_job
|
||||
|
||||
except Exception as e:
|
||||
job.last_run_at = datetime.now(timezone.utc)
|
||||
job.last_status = "failed"
|
||||
job.last_error = str(e)
|
||||
await job.save()
|
||||
logger.error(f"Cron job {job.id} ({job.name}) failed: {e}")
|
||||
return jsonify({"error": str(e)}), 500
|
||||
result = await execute_job(job, run_id, retry=True)
|
||||
return jsonify(result), 200 if result.get("ok") or result.get("skipped") else 409
|
||||
|
||||
@@ -6,6 +6,9 @@ from blueprints.scheduled_messages.models import MessageChannel
|
||||
|
||||
class CronJob(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
created_by = fields.ForeignKeyField(
|
||||
"models.User", null=True, related_name="cron_jobs", on_delete=fields.SET_NULL
|
||||
)
|
||||
name = fields.CharField(max_length=255)
|
||||
description = fields.TextField(null=True)
|
||||
prompt = fields.TextField()
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""One durable delivery per caller-supplied cron run identifier."""
|
||||
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from blueprints.scheduled_messages import delivery
|
||||
from .models import CronJob
|
||||
|
||||
|
||||
async def execute_job(job, run_id, *, retry=False):
|
||||
await delivery.expire_claims("cron")
|
||||
if not job.enabled:
|
||||
return {"skipped": True, "status": "disabled"}
|
||||
claimed = await delivery.claim("cron", job.id, run_id, retry=retry)
|
||||
if not claimed:
|
||||
from blueprints.scheduled_messages.models import JobDelivery
|
||||
|
||||
existing = await JobDelivery.get(kind="cron", job_id=str(job.id), run_id=run_id)
|
||||
return {"skipped": True, "status": existing.status}
|
||||
sending = False
|
||||
try:
|
||||
if not job.created_by_id:
|
||||
raise ValueError(
|
||||
"Cron job requires an explicit created_by owner before execution"
|
||||
)
|
||||
user = await job.created_by
|
||||
if user is None:
|
||||
raise ValueError("Cron job owner no longer exists")
|
||||
content = await delivery.agent_content(
|
||||
user, job.prompt, f"cron-{claimed.id}", "cron"
|
||||
)
|
||||
async with in_transaction() as db:
|
||||
active = (
|
||||
await CronJob.filter(id=job.id, enabled=True)
|
||||
.using_db(db)
|
||||
.update(updated_at=delivery.now())
|
||||
)
|
||||
if not active:
|
||||
await delivery.transition(claimed, "cancelled")
|
||||
return {"skipped": True, "status": "cancelled"}
|
||||
if not await delivery.transition(claimed, "sending"):
|
||||
return {"skipped": True, "status": "uncertain"}
|
||||
sending = True
|
||||
await delivery.send(job.channel, job.recipient, content, job.name)
|
||||
await delivery.transition(claimed, "sent")
|
||||
status, error = "sent", None
|
||||
except Exception as exc:
|
||||
status = (
|
||||
"uncertain"
|
||||
if sending and not isinstance(exc, delivery.SafeDeliveryError)
|
||||
else "failed"
|
||||
)
|
||||
error = str(exc)
|
||||
await delivery.transition(claimed, status, error)
|
||||
await CronJob.filter(id=job.id).update(
|
||||
last_run_at=delivery.now(),
|
||||
last_status="success" if status == "sent" else status,
|
||||
last_error=error,
|
||||
)
|
||||
return {"ok": status == "sent", "status": status, "error": error}
|
||||
@@ -7,7 +7,7 @@ import time
|
||||
from collections import defaultdict
|
||||
|
||||
import httpx
|
||||
from quart import Blueprint, current_app, request
|
||||
from quart import Blueprint, request
|
||||
|
||||
from blueprints.users.models import User
|
||||
from blueprints.conversation.logic import (
|
||||
@@ -15,8 +15,8 @@ from blueprints.conversation.logic import (
|
||||
add_message_to_conversation,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.models import Speaker
|
||||
from blueprints.conversation.prompts import SIMBA_SYSTEM_PROMPT
|
||||
from blueprints.conversation.runtime import invoke_agent_for_user
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
from . import models # noqa: F401 — register Tortoise ORM models
|
||||
from .helpers import generate_email_token, get_user_email_address # noqa: F401
|
||||
|
||||
@@ -193,18 +193,14 @@ async def webhook():
|
||||
|
||||
# Build messages payload
|
||||
try:
|
||||
messages = await conversation.messages.all()
|
||||
recent_messages = list(messages)[-20:]
|
||||
|
||||
messages_payload = [{"role": "system", "content": SIMBA_SYSTEM_PROMPT}]
|
||||
for msg in recent_messages[:-1]:
|
||||
role = "user" if msg.speaker == Speaker.USER else "assistant"
|
||||
messages_payload.append({"role": role, "content": msg.text})
|
||||
messages_payload.append({"role": "user", "content": body})
|
||||
|
||||
logger.info(f"Invoking LangChain agent with {len(messages_payload)} messages")
|
||||
response = await current_app.main_agent.ainvoke({"messages": messages_payload})
|
||||
response_text = response.get("messages", [])[-1].content
|
||||
response = await invoke_agent_for_user(
|
||||
user,
|
||||
body,
|
||||
str(conversation.id),
|
||||
channel="email",
|
||||
instructions="You are replying by email. Write a clear reply appropriate to that channel.",
|
||||
)
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
conversation, user, response.get("messages", [])
|
||||
)
|
||||
|
||||
@@ -4,9 +4,9 @@ import hashlib
|
||||
|
||||
def generate_email_token(user_id: str, secret: str) -> str:
|
||||
"""Generate a 16-char hex HMAC token for a user's email address."""
|
||||
return hmac.new(
|
||||
secret.encode(), str(user_id).encode(), hashlib.sha256
|
||||
).hexdigest()[:16]
|
||||
return hmac.new(secret.encode(), str(user_id).encode(), hashlib.sha256).hexdigest()[
|
||||
:16
|
||||
]
|
||||
|
||||
|
||||
def get_user_email_address(token: str, domain: str) -> str:
|
||||
|
||||
@@ -11,13 +11,12 @@ from quart import Blueprint, request, jsonify
|
||||
|
||||
from blueprints.users.models import User
|
||||
from blueprints.conversation.logic import (
|
||||
get_agent_for,
|
||||
get_conversation_for_channel,
|
||||
add_message_to_conversation,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.models import Conversation, ConversationMessage
|
||||
from blueprints.conversation.memory import build_system_prompt_with_memories
|
||||
from blueprints.conversation.runtime import invoke_agent_for_user
|
||||
from utils.strip_markdown import extract_agent_text, strip_markdown
|
||||
|
||||
imessage_blueprint = Blueprint("imessage_api", __name__, url_prefix="/api/imessage")
|
||||
@@ -344,24 +343,13 @@ async def _process_and_reply(
|
||||
else:
|
||||
user_content = content
|
||||
|
||||
system_prompt = await build_system_prompt_with_memories(str(user.id))
|
||||
system_prompt += (
|
||||
"\n\nIMPORTANT: You are replying via iMessage. Use plain text only. "
|
||||
"Do not use markdown tables, headers, bullet points, bold, italics, "
|
||||
"or any other markdown formatting. Write in natural prose."
|
||||
)
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
"system_prompt": system_prompt,
|
||||
}
|
||||
}
|
||||
logger.info(f"Invoking LangChain agent for conversation {conversation.id}")
|
||||
response = await get_agent_for(user).ainvoke(
|
||||
{"messages": [{"role": "user", "content": user_content}]},
|
||||
config=agent_config,
|
||||
response = await invoke_agent_for_user(
|
||||
user,
|
||||
content or "",
|
||||
str(conversation.id),
|
||||
channel="imessage",
|
||||
messages=[{"role": "user", "content": user_content}],
|
||||
instructions="You are replying via iMessage. Use plain text and natural prose; avoid markdown formatting.",
|
||||
)
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Authenticated, secret-free status of configured integrations and cached data."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
from quart_jwt_extended import get_jwt_identity
|
||||
|
||||
from blueprints.users.decorators import jwt_access_required
|
||||
from blueprints.users.models import User
|
||||
from blueprints.ynab.sync import _current_state, ynab_configured
|
||||
|
||||
integrations_blueprint = Blueprint(
|
||||
"integrations_api", __name__, url_prefix="/api/integrations"
|
||||
)
|
||||
|
||||
|
||||
def configuration_status(name: str, configured: bool) -> dict:
|
||||
return {
|
||||
"name": name,
|
||||
"status": "configured" if configured else "not_configured",
|
||||
"last_success_at": None,
|
||||
"message": "Configured; connection has not been checked."
|
||||
if configured
|
||||
else "Not configured.",
|
||||
}
|
||||
|
||||
|
||||
async def _ynab_status() -> dict:
|
||||
status = configuration_status("YNAB", ynab_configured())
|
||||
if not ynab_configured():
|
||||
return status
|
||||
try:
|
||||
state = await _current_state()
|
||||
if state is None or state.last_synced_at is None:
|
||||
return {
|
||||
**status,
|
||||
"status": "unavailable",
|
||||
"message": "No synchronized budget data is available.",
|
||||
}
|
||||
stamp = state.last_synced_at
|
||||
if stamp.tzinfo is None:
|
||||
stamp = stamp.replace(tzinfo=timezone.utc)
|
||||
age = max(0, (datetime.now(timezone.utc) - stamp).total_seconds())
|
||||
stale = age > 120 or bool(state.last_error)
|
||||
return {
|
||||
**status,
|
||||
"status": "stale" if stale else "ready",
|
||||
"last_success_at": stamp.isoformat(),
|
||||
"message": "Using cached budget data; synchronization is overdue or failed."
|
||||
if stale
|
||||
else "Budget data is current.",
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
**status,
|
||||
"status": "unavailable",
|
||||
"message": "Budget synchronization status is unavailable.",
|
||||
}
|
||||
|
||||
|
||||
async def integration_statuses(*, is_admin: bool) -> list[dict]:
|
||||
result = [await _ynab_status()]
|
||||
notes = configuration_status("Document search", bool(os.getenv("DATABASE_URL")))
|
||||
try:
|
||||
from blueprints.rag.logic import get_vector_store_stats
|
||||
|
||||
stats = await asyncio.to_thread(get_vector_store_stats)
|
||||
count = stats.get("total_documents", 0)
|
||||
notes.update(
|
||||
status="ready" if count else "unavailable",
|
||||
indexed_chunks=count,
|
||||
message=f"{count} indexed document chunks available."
|
||||
if count
|
||||
else "No documents are indexed yet.",
|
||||
)
|
||||
except Exception:
|
||||
notes.update(
|
||||
status="unavailable", message="Document index status is unavailable."
|
||||
)
|
||||
result.append(notes)
|
||||
for name, keys in [
|
||||
("Mealie", ("MEALIE_BASE_URL", "MEALIE_API_TOKEN")),
|
||||
("Obsidian", ("OBSIDIAN_VAULT_PATH",)),
|
||||
("Web search", ("TAVILY_API_KEY",)),
|
||||
("Local model", ("LLAMA_SERVER_URL",)),
|
||||
("Fallback model", ("OPENAI_API_KEY",)),
|
||||
("SimbaKit", ("SIMBAKIT_URL",)),
|
||||
]:
|
||||
result.append(configuration_status(name, all(os.getenv(k) for k in keys)))
|
||||
if is_admin:
|
||||
for name, keys in [
|
||||
("Homebox", ("HOMEBOX_URL", "HOMEBOX_TOKEN")),
|
||||
(
|
||||
"BookStack",
|
||||
("BOOKSTACK_BASE_URL", "BOOKSTACK_TOKEN_ID", "BOOKSTACK_TOKEN_SECRET"),
|
||||
),
|
||||
("Google", ("GOOGLE_TOKEN_FILE",)),
|
||||
("iMessage", ("SENDBLUE_API_KEY", "SENDBLUE_API_SECRET")),
|
||||
("Email", ("MAILGUN_API_KEY", "MAILGUN_DOMAIN")),
|
||||
]:
|
||||
result.append(configuration_status(name, all(os.getenv(k) for k in keys)))
|
||||
return result
|
||||
|
||||
|
||||
@integrations_blueprint.get("/")
|
||||
@jwt_access_required
|
||||
async def status():
|
||||
user = await User.get(id=get_jwt_identity())
|
||||
return jsonify(
|
||||
{
|
||||
"checked_at": datetime.now(timezone.utc).isoformat(),
|
||||
"integrations": await integration_statuses(is_admin=user.is_admin()),
|
||||
}
|
||||
)
|
||||
+28
-2
@@ -16,6 +16,7 @@ from langchain_text_splitters import (
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
from .fetchers import PaperlessNGXService
|
||||
from .retrieval import build_filter, citation_url, document_dates
|
||||
from utils.obsidian_service import ObsidianService
|
||||
|
||||
# Load environment variables
|
||||
@@ -142,6 +143,7 @@ def date_to_epoch(date_str: str) -> float:
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
tzinfo=datetime.timezone.utc,
|
||||
)
|
||||
|
||||
return date.timestamp()
|
||||
@@ -155,6 +157,9 @@ async def fetch_documents_from_paperless_ngx() -> list[Document]:
|
||||
for doc in data:
|
||||
metadata = {
|
||||
"source": "paperless",
|
||||
"document_id": doc.get("id"),
|
||||
"document_date": doc["created_date"][:10],
|
||||
"indexed_at": time.time(),
|
||||
"created_date": date_to_epoch(doc["created_date"]),
|
||||
"filename": doc["original_file_name"],
|
||||
"document_type": doctypes.get(doc["document_type"], ""),
|
||||
@@ -422,6 +427,8 @@ async def query_vector_store(
|
||||
query: str,
|
||||
source: str | None = None,
|
||||
k: int = 8,
|
||||
start_date: str | None = None,
|
||||
end_date: str | None = None,
|
||||
):
|
||||
"""Query the vector store with optional source filtering and MMR.
|
||||
|
||||
@@ -430,16 +437,35 @@ async def query_vector_store(
|
||||
source: Filter by source metadata (e.g., "obsidian", "paperless")
|
||||
k: Number of results to return
|
||||
"""
|
||||
if not isinstance(query, str) or not query.strip():
|
||||
raise ValueError("query must not be empty")
|
||||
if not isinstance(k, int) or not 1 <= k <= 20:
|
||||
raise ValueError("k must be between 1 and 20")
|
||||
filter_dict = build_filter(source, start_date, end_date)
|
||||
vector_store = _get_vector_store()
|
||||
filter_dict = {"source": source} if source else None
|
||||
retrieved_docs = await vector_store.amax_marginal_relevance_search(
|
||||
query,
|
||||
k=k,
|
||||
fetch_k=k * 3,
|
||||
filter=filter_dict,
|
||||
)
|
||||
if start_date or end_date:
|
||||
retrieved_docs = [
|
||||
doc
|
||||
for doc in retrieved_docs
|
||||
# Same "any date field in range" rule as the database filter.
|
||||
if any(
|
||||
(not start_date or stamp >= start_date)
|
||||
and (not end_date or stamp <= end_date)
|
||||
for stamp in document_dates(doc.metadata)
|
||||
)
|
||||
]
|
||||
serialized = "\n\n".join(
|
||||
(f"Source: {doc.metadata}\nContent: {doc.page_content}")
|
||||
(
|
||||
f"Source: {doc.metadata}\n"
|
||||
+ (f"Citation: {citation_url(doc)}\n" if citation_url(doc) else "")
|
||||
+ f"Content: {doc.page_content}"
|
||||
)
|
||||
for doc in retrieved_docs
|
||||
)
|
||||
return serialized, retrieved_docs
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Validated retrieval filters and stable, authenticated source references."""
|
||||
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
import re
|
||||
from uuid import UUID
|
||||
|
||||
|
||||
def parse_date(value: str | None) -> date | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value):
|
||||
raise ValueError
|
||||
return date.fromisoformat(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("Dates must be YYYY-MM-DD.") from exc
|
||||
|
||||
|
||||
def build_filter(
|
||||
source: str | None, start_date: str | None = None, end_date: str | None = None
|
||||
) -> dict | None:
|
||||
if source not in {None, "paperless", "obsidian"}:
|
||||
raise ValueError("source must be paperless or obsidian")
|
||||
start, end = parse_date(start_date), parse_date(end_date)
|
||||
if start and end and start > end:
|
||||
raise ValueError("start_date must be on or before end_date")
|
||||
parts = [{"source": source}] if source else []
|
||||
if start or end:
|
||||
strings, epochs = {}, {}
|
||||
if start:
|
||||
strings["$gte"] = start.isoformat()
|
||||
epochs["$gte"] = datetime.combine(start, time.min, timezone.utc).timestamp()
|
||||
if end:
|
||||
try:
|
||||
next_day = end + timedelta(days=1)
|
||||
except OverflowError as exc:
|
||||
raise ValueError("end_date is beyond the supported date range") from exc
|
||||
strings["$lt"] = next_day.isoformat()
|
||||
epochs["$lt"] = datetime.combine(
|
||||
next_day, time.min, timezone.utc
|
||||
).timestamp()
|
||||
|
||||
# Both newly normalized fields and existing indexed metadata remain usable.
|
||||
def date_bounds(field, bounds):
|
||||
clauses = [{field: {operator: value}} for operator, value in bounds.items()]
|
||||
return {"$and": clauses} if len(clauses) > 1 else clauses[0]
|
||||
|
||||
parts.append(
|
||||
{
|
||||
"$or": [
|
||||
date_bounds("document_date", strings),
|
||||
{
|
||||
"$and": [
|
||||
{"source": "paperless"},
|
||||
date_bounds("created_date", epochs),
|
||||
]
|
||||
},
|
||||
{"$and": [{"source": "obsidian"}, date_bounds("date", strings)]},
|
||||
{
|
||||
"$and": [
|
||||
{"source": "obsidian"},
|
||||
date_bounds("created_at", strings),
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
)
|
||||
return {"$and": parts} if len(parts) > 1 else parts[0] if parts else None
|
||||
|
||||
|
||||
def document_dates(metadata: dict) -> list[str]:
|
||||
"""Every date the search filter can match on, as YYYY-MM-DD."""
|
||||
dates = []
|
||||
for key in ("document_date", "date", "created_at"):
|
||||
value = metadata.get(key)
|
||||
if isinstance(value, (date, datetime)):
|
||||
dates.append(value.isoformat()[:10])
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
dates.append(date.fromisoformat(value[:10]).isoformat())
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
if metadata.get("created_date") is not None:
|
||||
dates.append(
|
||||
datetime.fromtimestamp(float(metadata["created_date"]), timezone.utc)
|
||||
.date()
|
||||
.isoformat()
|
||||
)
|
||||
except (ValueError, TypeError, OverflowError, OSError):
|
||||
pass
|
||||
return dates
|
||||
|
||||
|
||||
def document_date(metadata: dict) -> str | None:
|
||||
"""The date shown for a citation: the most specific one available."""
|
||||
dates = document_dates(metadata)
|
||||
return dates[0] if dates else None
|
||||
|
||||
|
||||
def citation_url(document) -> str | None:
|
||||
try:
|
||||
return "/sources/" + str(UUID(str(document.id)))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return None
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Read-only source chunks from the same authenticated shared retrieval corpus."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from urllib.parse import urlsplit
|
||||
from uuid import UUID
|
||||
|
||||
from quart import Blueprint, jsonify
|
||||
from sqlalchemy import text
|
||||
|
||||
from blueprints.conversation.artifacts_util import (
|
||||
obsidian_source_ref,
|
||||
vault_relative_path,
|
||||
)
|
||||
from blueprints.users.decorators import jwt_access_required
|
||||
from .retrieval import document_date
|
||||
|
||||
sources_blueprint = Blueprint("sources_api", __name__, url_prefix="/api/sources")
|
||||
|
||||
|
||||
def _load_chunk(identifier: str) -> dict | None:
|
||||
from .logic import _get_collection_id, _get_engine
|
||||
|
||||
collection = _get_collection_id()
|
||||
if not collection:
|
||||
return None
|
||||
with _get_engine().connect() as conn:
|
||||
row = conn.execute(
|
||||
text(
|
||||
"SELECT document, cmetadata FROM langchain_pg_embedding WHERE id=:id AND collection_id=:collection"
|
||||
),
|
||||
{"id": identifier, "collection": collection},
|
||||
).first()
|
||||
if row is None:
|
||||
return None
|
||||
metadata = row[1] or {}
|
||||
source = metadata.get("source")
|
||||
if source not in {"obsidian", "paperless"}:
|
||||
return None
|
||||
result = {
|
||||
"id": identifier,
|
||||
"source": source,
|
||||
"content": row[0],
|
||||
"date": document_date(metadata),
|
||||
"headings": [metadata[k] for k in ("h1", "h2", "h3") if metadata.get(k)],
|
||||
}
|
||||
if source == "obsidian":
|
||||
path = vault_relative_path(metadata.get("filepath") or "")
|
||||
result.update(title=metadata.get("title") or path or "Note", path=path)
|
||||
result["original_url"] = obsidian_source_ref(path).get("url") if path else None
|
||||
else:
|
||||
result.update(
|
||||
title=metadata.get("filename") or "Document",
|
||||
document_type=metadata.get("document_type"),
|
||||
)
|
||||
base = os.getenv("BASE_URL", "").rstrip("/")
|
||||
base = base if "://" in base else "http://" + base if base else ""
|
||||
parsed = urlsplit(base)
|
||||
document_id = metadata.get("document_id")
|
||||
result["original_url"] = (
|
||||
f"{base}/documents/{document_id}/details"
|
||||
if parsed.scheme in {"http", "https"}
|
||||
and parsed.hostname
|
||||
and not parsed.username
|
||||
and str(document_id).isdigit()
|
||||
else None
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@sources_blueprint.get("/<source_id>")
|
||||
@jwt_access_required
|
||||
async def get_source(source_id):
|
||||
try:
|
||||
identifier = str(UUID(source_id))
|
||||
except ValueError:
|
||||
return jsonify(error="source not found"), 404
|
||||
try:
|
||||
result = await asyncio.to_thread(_load_chunk, identifier)
|
||||
except Exception:
|
||||
return jsonify(error="Source storage is unavailable. Please try again."), 503
|
||||
if result is None:
|
||||
return jsonify(error="source not found"), 404
|
||||
response = jsonify(result)
|
||||
response.headers["Cache-Control"] = "no-store"
|
||||
return response
|
||||
@@ -3,7 +3,9 @@ import logging
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .validation import json_object
|
||||
from quart import Blueprint, request, jsonify
|
||||
from tortoise.expressions import Q
|
||||
|
||||
from blueprints.users.decorators import admin_required
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||
@@ -18,6 +20,8 @@ logger = logging.getLogger(__name__)
|
||||
def _serialize(msg: ScheduledMessage) -> dict:
|
||||
return {
|
||||
"id": str(msg.id),
|
||||
"series_id": str(msg.series_id or msg.id),
|
||||
"retryable": msg.status == MessageStatus.FAILED,
|
||||
"recipient": msg.recipient,
|
||||
"channel": msg.channel.value,
|
||||
"content": msg.content,
|
||||
@@ -35,16 +39,29 @@ def _serialize(msg: ScheduledMessage) -> dict:
|
||||
@scheduled_messages_blueprint.route("/", methods=["GET"])
|
||||
@admin_required
|
||||
async def list_messages():
|
||||
messages = await ScheduledMessage.all().order_by("-scheduled_at")
|
||||
messages = await ScheduledMessage.filter(deleted_at__isnull=True).order_by(
|
||||
"-scheduled_at"
|
||||
)
|
||||
return jsonify([_serialize(m) for m in messages])
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/", methods=["POST"])
|
||||
@admin_required
|
||||
async def create_message():
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
data, error = await json_object(
|
||||
(
|
||||
"recipient",
|
||||
"channel",
|
||||
"content",
|
||||
"subject",
|
||||
"scheduled_at",
|
||||
"recurrence",
|
||||
"status",
|
||||
),
|
||||
nullable=("subject",),
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
|
||||
recipient = (data.get("recipient") or "").strip()
|
||||
channel = data.get("channel")
|
||||
@@ -112,19 +129,109 @@ async def create_message():
|
||||
return jsonify(_serialize(msg)), 201
|
||||
|
||||
|
||||
async def _cancel(msg):
|
||||
"""Cancel an occurrence and stop its series without rewriting delivery outcomes.
|
||||
|
||||
Rows whose delivery has started sending keep their status (sent, uncertain or
|
||||
in flight); only work that has not reached a provider is cancelled.
|
||||
"""
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .models import JobDelivery
|
||||
|
||||
async with in_transaction() as db:
|
||||
series = msg.series_id or msg.id
|
||||
# Same lock order as delivery: series row first, so the "sending"
|
||||
# transition cannot interleave with this check.
|
||||
await (
|
||||
ScheduledMessage.filter(id=series)
|
||||
.using_db(db)
|
||||
.update(updated_at=datetime.now(timezone.utc))
|
||||
)
|
||||
members = await ScheduledMessage.filter(
|
||||
Q(id=msg.id) | Q(series_id=series),
|
||||
status__in=[
|
||||
MessageStatus.PENDING,
|
||||
MessageStatus.FAILED,
|
||||
MessageStatus.PROCESSING,
|
||||
],
|
||||
).using_db(db)
|
||||
started = set(
|
||||
await JobDelivery.filter(
|
||||
kind="scheduled",
|
||||
job_id__in=[str(m.id) for m in members],
|
||||
status__in=["sending", "sent", "uncertain"],
|
||||
)
|
||||
.using_db(db)
|
||||
.values_list("job_id", flat=True)
|
||||
)
|
||||
cancellable = [m.id for m in members if str(m.id) not in started]
|
||||
if cancellable:
|
||||
await (
|
||||
ScheduledMessage.filter(id__in=cancellable)
|
||||
.using_db(db)
|
||||
.update(status=MessageStatus.CANCELLED, series_id=series)
|
||||
)
|
||||
# Claiming sets series_id to the row's own id, so only a different series
|
||||
# id (or a predecessor) marks a one-off row as part of a series.
|
||||
recurring = (
|
||||
msg.recurrence != Recurrence.NONE
|
||||
or msg.previous_occurrence_id
|
||||
or (msg.series_id and msg.series_id != msg.id)
|
||||
)
|
||||
if msg.id not in cancellable and not recurring:
|
||||
return jsonify(
|
||||
{"error": "Delivery already started; it cannot be cancelled"}
|
||||
), 409
|
||||
if recurring and not cancellable:
|
||||
# Nothing pending to mark: a hidden tombstone stops future occurrences.
|
||||
await ScheduledMessage.create(
|
||||
series_id=series,
|
||||
recipient=msg.recipient,
|
||||
channel=msg.channel,
|
||||
content=msg.content,
|
||||
subject=msg.subject,
|
||||
scheduled_at=msg.scheduled_at,
|
||||
recurrence=Recurrence.NONE,
|
||||
status=MessageStatus.CANCELLED,
|
||||
deleted_at=datetime.now(timezone.utc),
|
||||
created_by_id=msg.created_by_id,
|
||||
using_db=db,
|
||||
)
|
||||
await msg.refresh_from_db()
|
||||
return jsonify(_serialize(msg))
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>", methods=["PUT"])
|
||||
@admin_required
|
||||
async def update_message(msg_id: str):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id, deleted_at__isnull=True)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
data, error = await json_object(
|
||||
(
|
||||
"recipient",
|
||||
"channel",
|
||||
"content",
|
||||
"subject",
|
||||
"scheduled_at",
|
||||
"recurrence",
|
||||
"status",
|
||||
),
|
||||
nullable=("subject",),
|
||||
)
|
||||
if error:
|
||||
return error
|
||||
|
||||
if data.get("status") == "cancelled":
|
||||
return await _cancel(msg)
|
||||
if msg.status not in (MessageStatus.PENDING, MessageStatus.FAILED):
|
||||
return jsonify({"error": "Can only update pending or failed messages"}), 400
|
||||
|
||||
data = await request.get_json()
|
||||
if not data:
|
||||
return jsonify({"error": "Invalid payload"}), 400
|
||||
blank = [f for f in ("recipient", "content") if f in data and not data[f].strip()]
|
||||
if blank:
|
||||
return jsonify({"error": f"{', '.join(blank)} cannot be empty"}), 400
|
||||
|
||||
rescheduled = False
|
||||
if "recipient" in data:
|
||||
@@ -137,7 +244,7 @@ async def update_message(msg_id: str):
|
||||
if "content" in data:
|
||||
msg.content = data["content"].strip()
|
||||
if "subject" in data:
|
||||
msg.subject = data["subject"].strip() or None
|
||||
msg.subject = (data["subject"] or "").strip() or None
|
||||
if "recurrence" in data:
|
||||
try:
|
||||
msg.recurrence = Recurrence(data["recurrence"])
|
||||
@@ -156,16 +263,31 @@ async def update_message(msg_id: str):
|
||||
rescheduled = True
|
||||
except ValueError:
|
||||
return jsonify({"error": "Invalid scheduled_at format"}), 400
|
||||
if "status" in data and data["status"] == "cancelled":
|
||||
msg.status = MessageStatus.CANCELLED
|
||||
elif rescheduled:
|
||||
msg.status = MessageStatus.PENDING
|
||||
if rescheduled and msg.status == MessageStatus.PENDING:
|
||||
msg.error_message = None
|
||||
|
||||
if msg.channel == MessageChannel.EMAIL and not msg.subject:
|
||||
return jsonify({"error": "subject is required for email messages"}), 400
|
||||
|
||||
await msg.save()
|
||||
values = {
|
||||
field: getattr(msg, field)
|
||||
for field in (
|
||||
"recipient",
|
||||
"channel",
|
||||
"content",
|
||||
"subject",
|
||||
"scheduled_at",
|
||||
"recurrence",
|
||||
"use_agent",
|
||||
"status",
|
||||
"error_message",
|
||||
)
|
||||
}
|
||||
changed = await ScheduledMessage.filter(
|
||||
id=msg.id, status__in=[MessageStatus.PENDING, MessageStatus.FAILED]
|
||||
).update(**values)
|
||||
if not changed:
|
||||
return jsonify({"error": "Message was claimed; update rejected"}), 409
|
||||
|
||||
if rescheduled and msg.status == MessageStatus.PENDING:
|
||||
from utils.cronicle import create_message_event
|
||||
@@ -197,12 +319,80 @@ async def trigger_due_messages():
|
||||
@scheduled_messages_blueprint.route("/<msg_id>", methods=["DELETE"])
|
||||
@admin_required
|
||||
async def delete_message(msg_id: str):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id)
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id, deleted_at__isnull=True)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
|
||||
if msg.status not in (MessageStatus.PENDING, MessageStatus.CANCELLED):
|
||||
return jsonify({"error": "Can only delete pending or cancelled messages"}), 400
|
||||
|
||||
await msg.delete()
|
||||
if msg.recurrence != Recurrence.NONE or msg.series_id or msg.previous_occurrence_id:
|
||||
# Keep a durable cancellation tombstone so recurrence repair cannot revive
|
||||
# it, even if this occurrence was since edited to be non-recurring.
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
async with in_transaction() as db:
|
||||
series = msg.series_id or msg.id
|
||||
await (
|
||||
ScheduledMessage.filter(id=series)
|
||||
.using_db(db)
|
||||
.update(updated_at=datetime.now(timezone.utc))
|
||||
)
|
||||
deleted = (
|
||||
await ScheduledMessage.filter(
|
||||
id=msg.id,
|
||||
status__in=[MessageStatus.PENDING, MessageStatus.CANCELLED],
|
||||
)
|
||||
.using_db(db)
|
||||
.update(
|
||||
status=MessageStatus.CANCELLED,
|
||||
series_id=series,
|
||||
deleted_at=datetime.now(timezone.utc),
|
||||
)
|
||||
)
|
||||
if not deleted:
|
||||
return jsonify({"error": "Message was claimed; deletion rejected"}), 409
|
||||
await (
|
||||
ScheduledMessage.filter(
|
||||
series_id=series,
|
||||
status__in=[
|
||||
MessageStatus.PENDING,
|
||||
MessageStatus.FAILED,
|
||||
MessageStatus.PROCESSING,
|
||||
],
|
||||
)
|
||||
.using_db(db)
|
||||
.update(status=MessageStatus.CANCELLED)
|
||||
)
|
||||
else:
|
||||
deleted = await ScheduledMessage.filter(
|
||||
id=msg.id, status__in=[MessageStatus.PENDING, MessageStatus.CANCELLED]
|
||||
).delete()
|
||||
if not deleted:
|
||||
return jsonify({"error": "Message was claimed; deletion rejected"}), 409
|
||||
return jsonify({"status": "deleted"})
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>/deliveries", methods=["GET"])
|
||||
@admin_required
|
||||
async def delivery_history(msg_id):
|
||||
if not await ScheduledMessage.filter(id=msg_id, deleted_at__isnull=True).exists():
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
from .delivery import history
|
||||
|
||||
return jsonify(await history("scheduled", msg_id))
|
||||
|
||||
|
||||
@scheduled_messages_blueprint.route("/<msg_id>/retry", methods=["POST"])
|
||||
@admin_required
|
||||
async def retry_message(msg_id):
|
||||
msg = await ScheduledMessage.get_or_none(id=msg_id, deleted_at__isnull=True)
|
||||
if not msg:
|
||||
return jsonify({"error": "Not found"}), 404
|
||||
if msg.status != MessageStatus.FAILED:
|
||||
return jsonify({"error": "Only safe failed deliveries can be retried"}), 409
|
||||
from .scheduler import process_message
|
||||
|
||||
status = await process_message(msg, retry=True)
|
||||
await msg.refresh_from_db()
|
||||
return jsonify(_serialize(msg)), 200 if status == "sent" else 409
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""DB compare-and-set claims shared by scheduled and cron delivery.
|
||||
|
||||
The sending marker is committed BEFORE calling the provider. Any provider
|
||||
exception is ambiguous unless it explicitly guarantees no external effect.
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import httpx
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from .models import JobAttempt, JobDelivery
|
||||
|
||||
|
||||
class SafeDeliveryError(Exception):
|
||||
"""Provider rejected the request before any delivery could occur."""
|
||||
|
||||
|
||||
def now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
async def claim(kind, job_id, run_id, *, retry=False):
|
||||
delivery, _ = await JobDelivery.get_or_create(
|
||||
kind=kind, job_id=str(job_id), run_id=run_id
|
||||
)
|
||||
token = uuid.uuid4()
|
||||
async with in_transaction() as db:
|
||||
won = (
|
||||
await JobDelivery.filter(
|
||||
id=delivery.id, status="failed" if retry else "pending"
|
||||
)
|
||||
.using_db(db)
|
||||
.update(
|
||||
status="processing", claim_token=token, claimed_at=now(), error=None
|
||||
)
|
||||
)
|
||||
if not won:
|
||||
return None
|
||||
await JobAttempt.create(delivery_id=delivery.id, claim_token=token, using_db=db)
|
||||
return await JobDelivery.get(id=delivery.id)
|
||||
|
||||
|
||||
async def transition(delivery, status, error=None):
|
||||
async with in_transaction() as db:
|
||||
won = (
|
||||
await JobDelivery.filter(
|
||||
id=delivery.id,
|
||||
claim_token=delivery.claim_token,
|
||||
status__in=["processing", "sending"],
|
||||
)
|
||||
.using_db(db)
|
||||
.update(status=status, error=error, updated_at=now())
|
||||
)
|
||||
if won:
|
||||
await (
|
||||
JobAttempt.filter(claim_token=delivery.claim_token)
|
||||
.using_db(db)
|
||||
.update(
|
||||
status=status,
|
||||
error=error,
|
||||
finished_at=None if status == "sending" else now(),
|
||||
)
|
||||
)
|
||||
return bool(won)
|
||||
|
||||
|
||||
async def expire_claims(kind, *, age=timedelta(hours=1)):
|
||||
"""Abandoned work requires reconciliation, never an automatic resend."""
|
||||
stale = await JobDelivery.filter(
|
||||
kind=kind, status__in=["processing", "sending"], claimed_at__lt=now() - age
|
||||
)
|
||||
for delivery in stale:
|
||||
await transition(
|
||||
delivery, "uncertain", "Worker abandoned; delivery requires reconciliation"
|
||||
)
|
||||
|
||||
|
||||
async def agent_content(user, prompt, thread_id, channel):
|
||||
from blueprints.conversation.runtime import invoke_agent_for_user
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
|
||||
response = await invoke_agent_for_user(user, prompt, thread_id, channel=channel)
|
||||
messages = response.get("messages", [])
|
||||
if not messages:
|
||||
raise ValueError("Agent returned no messages")
|
||||
last = messages[-1]
|
||||
content = last.get("content") if isinstance(last, dict) else last.content
|
||||
content = extract_agent_text(content)
|
||||
if not content:
|
||||
raise ValueError("Agent returned no text")
|
||||
return content
|
||||
|
||||
|
||||
async def send(channel, recipient, content, subject=None):
|
||||
from .models import MessageChannel
|
||||
|
||||
try:
|
||||
if channel == MessageChannel.IMESSAGE:
|
||||
from blueprints.imessage import send_imessage
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(recipient, strip_markdown(content))
|
||||
elif channel == MessageChannel.EMAIL:
|
||||
# The shared reply helper logs provider failures and returns None.
|
||||
# Delivery tracking requires an explicit provider acknowledgement.
|
||||
api_key = os.getenv("MAILGUN_API_KEY")
|
||||
domain = os.getenv("MAILGUN_DOMAIN")
|
||||
if not api_key or not domain:
|
||||
raise SafeDeliveryError(
|
||||
"MAILGUN_API_KEY or MAILGUN_DOMAIN not configured"
|
||||
)
|
||||
subject = subject or "(no subject)"
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
f"https://api.mailgun.net/v3/{domain}/messages",
|
||||
auth=("api", api_key),
|
||||
data={
|
||||
"from": f"Simba <simba@{domain}>",
|
||||
"to": recipient,
|
||||
"subject": subject
|
||||
if subject.startswith("Re:")
|
||||
else f"Re: {subject}",
|
||||
"text": content,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
response.raise_for_status()
|
||||
else:
|
||||
raise SafeDeliveryError("Unsupported channel")
|
||||
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout) as exc:
|
||||
raise SafeDeliveryError(str(exc)) from exc
|
||||
except httpx.HTTPStatusError as exc:
|
||||
# Only explicit request rejection is safe. Server errors/timeouts may
|
||||
# follow an accepted delivery and must remain uncertain.
|
||||
if exc.response.status_code in {400, 401, 403, 404, 405, 413, 422, 429}:
|
||||
raise SafeDeliveryError(str(exc)) from exc
|
||||
raise
|
||||
|
||||
|
||||
async def history(kind, job_id):
|
||||
deliveries = await JobDelivery.filter(kind=kind, job_id=str(job_id)).order_by(
|
||||
"-created_at"
|
||||
)
|
||||
result = []
|
||||
for d in deliveries:
|
||||
attempts = await JobAttempt.filter(delivery_id=d.id).order_by("started_at")
|
||||
result.append(
|
||||
{
|
||||
"id": str(d.id),
|
||||
"run_id": d.run_id,
|
||||
"status": d.status,
|
||||
"error": d.error,
|
||||
"retryable": d.status == "failed",
|
||||
"attempts": [
|
||||
{
|
||||
"id": str(a.id),
|
||||
"status": a.status,
|
||||
"error": a.error,
|
||||
"started_at": a.started_at.isoformat(),
|
||||
"finished_at": a.finished_at.isoformat()
|
||||
if a.finished_at
|
||||
else None,
|
||||
}
|
||||
for a in attempts
|
||||
],
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -11,6 +11,8 @@ class MessageChannel(enum.Enum):
|
||||
|
||||
class MessageStatus(enum.Enum):
|
||||
PENDING = "pending"
|
||||
PROCESSING = "processing"
|
||||
UNCERTAIN = "uncertain"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
@@ -25,6 +27,14 @@ class Recurrence(enum.Enum):
|
||||
|
||||
class ScheduledMessage(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
deleted_at = fields.DatetimeField(null=True)
|
||||
series_id = fields.UUIDField(null=True)
|
||||
previous_occurrence = fields.OneToOneField(
|
||||
"models.ScheduledMessage",
|
||||
null=True,
|
||||
related_name="next_occurrence",
|
||||
on_delete=fields.SET_NULL,
|
||||
)
|
||||
recipient = fields.CharField(max_length=255)
|
||||
channel = fields.CharEnumField(enum_type=MessageChannel, max_length=20)
|
||||
content = fields.TextField()
|
||||
@@ -46,3 +56,35 @@ class ScheduledMessage(Model):
|
||||
|
||||
class Meta:
|
||||
table = "scheduled_messages"
|
||||
|
||||
|
||||
class JobDelivery(Model):
|
||||
"""Durable run identity; never reclaim ambiguous external deliveries."""
|
||||
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
kind = fields.CharField(max_length=20)
|
||||
job_id = fields.CharField(max_length=36)
|
||||
run_id = fields.CharField(max_length=255)
|
||||
status = fields.CharField(max_length=20, default="pending")
|
||||
claim_token = fields.UUIDField(null=True)
|
||||
claimed_at = fields.DatetimeField(null=True)
|
||||
error = fields.TextField(null=True)
|
||||
created_at = fields.DatetimeField(auto_now_add=True)
|
||||
updated_at = fields.DatetimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
table = "job_deliveries"
|
||||
unique_together = (("kind", "job_id", "run_id"),)
|
||||
|
||||
|
||||
class JobAttempt(Model):
|
||||
id = fields.UUIDField(primary_key=True)
|
||||
delivery = fields.ForeignKeyField("models.JobDelivery", related_name="attempts")
|
||||
claim_token = fields.UUIDField(unique=True)
|
||||
status = fields.CharField(max_length=20, default="processing")
|
||||
error = fields.TextField(null=True)
|
||||
started_at = fields.DatetimeField(auto_now_add=True)
|
||||
finished_at = fields.DatetimeField(null=True)
|
||||
|
||||
class Meta:
|
||||
table = "job_attempts"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from quart import current_app
|
||||
|
||||
from .models import ScheduledMessage, MessageChannel, MessageStatus, Recurrence
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from tortoise.transactions import in_transaction
|
||||
|
||||
from . import delivery as transport
|
||||
from .models import ScheduledMessage, MessageStatus, Recurrence, JobDelivery
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
RECURRENCE_DELTAS = {
|
||||
Recurrence.DAILY: relativedelta(days=1),
|
||||
Recurrence.WEEKLY: relativedelta(weeks=1),
|
||||
@@ -15,25 +15,39 @@ RECURRENCE_DELTAS = {
|
||||
}
|
||||
|
||||
|
||||
async def _run_agent(prompt: str, thread_id: str) -> str:
|
||||
response = await current_app.main_agent.ainvoke(
|
||||
{"messages": [{"role": "user", "content": prompt}]},
|
||||
config={"configurable": {"thread_id": thread_id}},
|
||||
)
|
||||
return response.get("messages", [])[-1].content
|
||||
def run_id(msg):
|
||||
# Each recurring occurrence has its own row; edits keep the same identity.
|
||||
return str(msg.id)
|
||||
|
||||
|
||||
async def _schedule_next_occurrence(msg: ScheduledMessage):
|
||||
async def _schedule_next_occurrence(msg, db=None):
|
||||
if db is None:
|
||||
async with in_transaction() as connection:
|
||||
return await _schedule_next_occurrence(msg, connection)
|
||||
delta = RECURRENCE_DELTAS.get(msg.recurrence)
|
||||
if not delta:
|
||||
return
|
||||
|
||||
series = msg.series_id or msg.id
|
||||
# Serialize series creation/cancellation on the original occurrence.
|
||||
await (
|
||||
ScheduledMessage.filter(id=series)
|
||||
.using_db(db)
|
||||
.update(updated_at=transport.now())
|
||||
)
|
||||
if (
|
||||
await ScheduledMessage.filter(series_id=series, status=MessageStatus.CANCELLED)
|
||||
.using_db(db)
|
||||
.exists()
|
||||
):
|
||||
return
|
||||
next_at = msg.scheduled_at + delta
|
||||
now = datetime.now(timezone.utc)
|
||||
while next_at <= now:
|
||||
next_at += delta
|
||||
|
||||
await ScheduledMessage.create(
|
||||
await ScheduledMessage.get_or_create(
|
||||
previous_occurrence_id=msg.id,
|
||||
defaults=dict(
|
||||
series_id=series,
|
||||
recipient=msg.recipient,
|
||||
channel=msg.channel,
|
||||
content=msg.content,
|
||||
@@ -42,61 +56,137 @@ async def _schedule_next_occurrence(msg: ScheduledMessage):
|
||||
recurrence=msg.recurrence,
|
||||
use_agent=msg.use_agent,
|
||||
created_by_id=msg.created_by_id,
|
||||
),
|
||||
using_db=db,
|
||||
)
|
||||
logger.info(
|
||||
f"Scheduled next {msg.recurrence.value} occurrence for {msg.id} at {next_at.isoformat()}"
|
||||
|
||||
|
||||
async def process_message(msg, *, retry=False):
|
||||
async with in_transaction() as db:
|
||||
# Always lock the series before an occurrence, matching cancellation.
|
||||
await (
|
||||
ScheduledMessage.filter(id=msg.series_id or msg.id)
|
||||
.using_db(db)
|
||||
.update(updated_at=transport.now())
|
||||
)
|
||||
eligible = ScheduledMessage.filter(
|
||||
id=msg.id, status=MessageStatus.FAILED if retry else MessageStatus.PENDING
|
||||
)
|
||||
if not retry:
|
||||
eligible = eligible.filter(scheduled_at__lte=transport.now())
|
||||
won = await eligible.using_db(db).update(
|
||||
status=MessageStatus.PROCESSING, series_id=msg.series_id or msg.id
|
||||
)
|
||||
if not won:
|
||||
return "skipped"
|
||||
msg = await ScheduledMessage.get(id=msg.id).using_db(db)
|
||||
claimed = await transport.claim("scheduled", msg.id, run_id(msg), retry=retry)
|
||||
if not claimed:
|
||||
# Preserve the authoritative delivery state if the job was rescheduled
|
||||
# onto an occurrence already delivered.
|
||||
d = await JobDelivery.get(
|
||||
kind="scheduled", job_id=str(msg.id), run_id=run_id(msg)
|
||||
)
|
||||
status = (
|
||||
MessageStatus(d.status)
|
||||
if d.status in {s.value for s in MessageStatus}
|
||||
else MessageStatus.UNCERTAIN
|
||||
)
|
||||
await ScheduledMessage.filter(id=msg.id).using_db(db).update(status=status)
|
||||
return "skipped"
|
||||
sending = False
|
||||
try:
|
||||
content = msg.content
|
||||
if msg.use_agent:
|
||||
user = await msg.created_by
|
||||
content = await transport.agent_content(
|
||||
user, content, f"scheduled-{claimed.id}", "scheduled"
|
||||
)
|
||||
# A cancellation which wins before this transaction prevents sending.
|
||||
async with in_transaction() as db:
|
||||
await (
|
||||
ScheduledMessage.filter(id=msg.series_id or msg.id)
|
||||
.using_db(db)
|
||||
.update(updated_at=transport.now())
|
||||
)
|
||||
active = (
|
||||
await ScheduledMessage.filter(
|
||||
id=msg.id, status=MessageStatus.PROCESSING
|
||||
)
|
||||
.using_db(db)
|
||||
.update(updated_at=transport.now())
|
||||
)
|
||||
if not active:
|
||||
await transport.transition(claimed, "cancelled")
|
||||
return "cancelled"
|
||||
if not await transport.transition(claimed, "sending"):
|
||||
return "skipped"
|
||||
sending = True
|
||||
await transport.send(msg.channel, msg.recipient, content, msg.subject)
|
||||
async with in_transaction() as db:
|
||||
await (
|
||||
ScheduledMessage.filter(id=msg.series_id or msg.id)
|
||||
.using_db(db)
|
||||
.update(updated_at=transport.now())
|
||||
)
|
||||
await transport.transition(claimed, "sent")
|
||||
active = (
|
||||
await ScheduledMessage.filter(
|
||||
id=msg.id, status=MessageStatus.PROCESSING
|
||||
)
|
||||
.using_db(db)
|
||||
.update(status=MessageStatus.SENT, error_message=None)
|
||||
)
|
||||
if active:
|
||||
await _schedule_next_occurrence(msg, db)
|
||||
return "sent"
|
||||
except Exception as exc:
|
||||
status = (
|
||||
"uncertain"
|
||||
if sending and not isinstance(exc, transport.SafeDeliveryError)
|
||||
else "failed"
|
||||
)
|
||||
await transport.transition(claimed, status, str(exc))
|
||||
await ScheduledMessage.filter(
|
||||
id=msg.id, status=MessageStatus.PROCESSING
|
||||
).update(status=MessageStatus(status), error_message=str(exc))
|
||||
logger.exception("Scheduled delivery %s ended as %s", msg.id, status)
|
||||
return status
|
||||
|
||||
|
||||
async def process_due_messages() -> dict:
|
||||
"""Process all currently due pending messages. Returns a result summary."""
|
||||
now = datetime.now(timezone.utc)
|
||||
due = await ScheduledMessage.filter(
|
||||
status=MessageStatus.PENDING,
|
||||
scheduled_at__lte=now,
|
||||
).all()
|
||||
|
||||
sent, failed = 0, 0
|
||||
for msg in due:
|
||||
try:
|
||||
send_content = msg.content
|
||||
|
||||
if msg.use_agent:
|
||||
send_content = await _run_agent(
|
||||
msg.content, f"scheduled-{uuid.uuid4()}"
|
||||
await transport.expire_claims("scheduled")
|
||||
uncertain = await JobDelivery.filter(kind="scheduled", status="uncertain")
|
||||
for d in uncertain:
|
||||
await ScheduledMessage.filter(
|
||||
id=d.job_id, status=MessageStatus.PROCESSING
|
||||
).update(status=MessageStatus.UNCERTAIN, error_message=d.error)
|
||||
# Recover recurrence creation if an earlier successful send was committed.
|
||||
# Only rows sent through delivery claims qualify: rows sent before migration
|
||||
# 20 have no successor links, and repairing them would fork each historical
|
||||
# occurrence into its own new series.
|
||||
orphans = await ScheduledMessage.filter(
|
||||
status=MessageStatus.SENT,
|
||||
deleted_at__isnull=True,
|
||||
next_occurrence__id__isnull=True,
|
||||
).exclude(recurrence=Recurrence.NONE)
|
||||
if orphans:
|
||||
claimed_sends = set(
|
||||
await JobDelivery.filter(
|
||||
kind="scheduled",
|
||||
status="sent",
|
||||
job_id__in=[str(m.id) for m in orphans],
|
||||
).values_list("job_id", flat=True)
|
||||
)
|
||||
|
||||
if msg.channel == MessageChannel.IMESSAGE:
|
||||
from blueprints.imessage import send_imessage
|
||||
from utils.strip_markdown import strip_markdown
|
||||
|
||||
await send_imessage(msg.recipient, strip_markdown(send_content))
|
||||
|
||||
elif msg.channel == MessageChannel.EMAIL:
|
||||
from blueprints.email import send_email_reply
|
||||
|
||||
await send_email_reply(
|
||||
to=msg.recipient,
|
||||
subject=msg.subject or "(no subject)",
|
||||
body=send_content,
|
||||
)
|
||||
|
||||
msg.status = MessageStatus.SENT
|
||||
msg.error_message = None
|
||||
await msg.save()
|
||||
logger.info(
|
||||
f"Sent scheduled {msg.channel.value} message {msg.id} to {msg.recipient}"
|
||||
)
|
||||
sent += 1
|
||||
|
||||
if msg.recurrence != Recurrence.NONE:
|
||||
for msg in orphans:
|
||||
if str(msg.id) in claimed_sends:
|
||||
await _schedule_next_occurrence(msg)
|
||||
|
||||
except Exception as e:
|
||||
msg.status = MessageStatus.FAILED
|
||||
msg.error_message = str(e)
|
||||
await msg.save()
|
||||
logger.error(f"Failed to send scheduled message {msg.id}: {e}")
|
||||
failed += 1
|
||||
|
||||
return {"sent": sent, "failed": failed}
|
||||
due = await ScheduledMessage.filter(
|
||||
status=MessageStatus.PENDING, scheduled_at__lte=transport.now()
|
||||
)
|
||||
counts = {"sent": 0, "failed": 0, "uncertain": 0}
|
||||
for msg in due:
|
||||
result = await process_message(msg)
|
||||
if result in counts:
|
||||
counts[result] += 1
|
||||
return counts
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Request-body checks shared by the scheduled message and cron endpoints."""
|
||||
|
||||
from quart import jsonify, request
|
||||
|
||||
|
||||
async def json_object(text_fields: tuple[str, ...], nullable: tuple[str, ...] = ()):
|
||||
"""Return (data, None) for a non-empty object whose text fields are strings.
|
||||
|
||||
Fields in `nullable` may also be an explicit null. Otherwise return
|
||||
(None, error_response) so malformed bodies get a 400 instead of failing on
|
||||
`.get`/`.strip` with a 500.
|
||||
"""
|
||||
data = await request.get_json(silent=True)
|
||||
if not isinstance(data, dict) or not data:
|
||||
return None, (jsonify({"error": "Invalid payload"}), 400)
|
||||
wrong = [
|
||||
name
|
||||
for name in text_fields
|
||||
if name in data
|
||||
and not isinstance(data[name], str)
|
||||
and not (data[name] is None and name in nullable)
|
||||
]
|
||||
if wrong:
|
||||
return None, (jsonify({"error": f"{', '.join(wrong)} must be text"}), 400)
|
||||
return data, None
|
||||
@@ -9,11 +9,11 @@ from twilio.twiml.messaging_response import MessagingResponse
|
||||
|
||||
from blueprints.users.models import User
|
||||
from blueprints.conversation.logic import (
|
||||
get_agent_for,
|
||||
get_conversation_for_channel,
|
||||
add_message_to_conversation,
|
||||
persist_agent_tool_messages,
|
||||
)
|
||||
from blueprints.conversation.runtime import invoke_agent_for_user
|
||||
from utils.strip_markdown import extract_agent_text
|
||||
|
||||
whatsapp_blueprint = Blueprint("whatsapp_api", __name__, url_prefix="/api/whatsapp")
|
||||
@@ -177,17 +177,11 @@ async def webhook():
|
||||
)
|
||||
|
||||
try:
|
||||
agent_config = {
|
||||
"configurable": {
|
||||
"user_id": str(user.id),
|
||||
"is_admin": user.is_admin(),
|
||||
"thread_id": str(conversation.id),
|
||||
}
|
||||
}
|
||||
logger.info(f"Invoking LangChain agent for conversation {conversation.id}")
|
||||
response = await get_agent_for(user).ainvoke(
|
||||
{"messages": [{"role": "user", "content": body}]},
|
||||
config=agent_config,
|
||||
response = await invoke_agent_for_user(
|
||||
user,
|
||||
body,
|
||||
str(conversation.id),
|
||||
channel="whatsapp",
|
||||
)
|
||||
response_text = extract_agent_text(response.get("messages", [])[-1].content)
|
||||
await persist_agent_tool_messages(
|
||||
|
||||
@@ -14,6 +14,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from contextvars import ContextVar
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from decimal import Decimal
|
||||
@@ -46,6 +47,9 @@ FAILURE_BACKOFF_SECONDS = 60
|
||||
LOOKUP_CHUNK = 2000
|
||||
|
||||
_sync_lock = asyncio.Lock()
|
||||
tool_data_freshness: ContextVar[dict | None] = ContextVar(
|
||||
"ynab_tool_data_freshness", default=None
|
||||
)
|
||||
_last_failure = float("-inf")
|
||||
|
||||
|
||||
@@ -616,6 +620,7 @@ async def ensure_fresh(max_age_seconds: int = 120) -> YnabSyncState | None:
|
||||
``FAILURE_BACKOFF_SECONDS``. Returns None only if the mirror has never
|
||||
been populated.
|
||||
"""
|
||||
tool_data_freshness.set(None)
|
||||
state = await _current_state()
|
||||
if not _is_fresh(state, max_age_seconds) and not _backing_off():
|
||||
try:
|
||||
@@ -625,6 +630,14 @@ async def ensure_fresh(max_age_seconds: int = 120) -> YnabSyncState | None:
|
||||
state = await _current_state()
|
||||
if state is None or state.server_knowledge is None:
|
||||
return None
|
||||
stamp = _norm(state.last_synced_at) if state.last_synced_at else None
|
||||
tool_data_freshness.set(
|
||||
{
|
||||
"system": "ynab",
|
||||
"as_of": stamp.isoformat() if stamp else None,
|
||||
"stale": not _is_fresh(state, max_age_seconds),
|
||||
}
|
||||
)
|
||||
return state
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from blueprints.conversation.artifacts_util import (
|
||||
make_artifact,
|
||||
)
|
||||
|
||||
from .sync import ensure_fresh
|
||||
from .sync import ensure_fresh, tool_data_freshness
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -64,8 +64,32 @@ def ynab_tool_errors(fn):
|
||||
|
||||
@functools.wraps(fn)
|
||||
async def wrapper(*args, **kwargs):
|
||||
tool_data_freshness.set(None)
|
||||
try:
|
||||
return await fn(*args, **kwargs)
|
||||
result = await fn(*args, **kwargs)
|
||||
freshness = tool_data_freshness.get()
|
||||
if not freshness:
|
||||
return result
|
||||
warning = (
|
||||
(
|
||||
f"\nData last synchronized: {freshness['as_of'] or 'unknown'}. "
|
||||
"WARNING: cached budget data is stale; disclose this when answering."
|
||||
)
|
||||
if freshness["stale"]
|
||||
else ""
|
||||
)
|
||||
if isinstance(result, tuple) and len(result) == 2:
|
||||
content, artifact = result
|
||||
if isinstance(artifact, dict):
|
||||
artifact = {
|
||||
**artifact,
|
||||
"source_ref": {
|
||||
**(artifact.get("source_ref") or {}),
|
||||
**freshness,
|
||||
},
|
||||
}
|
||||
return str(content) + warning, artifact
|
||||
return str(result) + warning
|
||||
except Exception as e:
|
||||
logger.exception(f"{fn.__name__} failed")
|
||||
return f"YNAB error in {fn.__name__}: {type(e).__name__}: {e}"
|
||||
|
||||
@@ -14,6 +14,7 @@ TORTOISE_CONFIG = {
|
||||
"models": {
|
||||
"models": [
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.conversation.run_models",
|
||||
"blueprints.users.models",
|
||||
"blueprints.email.models",
|
||||
"blueprints.scheduled_messages.models",
|
||||
|
||||
@@ -12,6 +12,9 @@ x-raggr-common: &raggr-common
|
||||
- RESPONSE_RESERVE_TOKENS=${RESPONSE_RESERVE_TOKENS:-4096}
|
||||
- CHECKPOINT_DB_PATH=${CHECKPOINT_DB_PATH:-/app/checkpoints/checkpoints.db}
|
||||
- CONVERSATION_SUMMARY_DB_PATH=${CONVERSATION_SUMMARY_DB_PATH:-${CHECKPOINT_DB_PATH:-/app/checkpoints/checkpoints.db}.summaries.db}
|
||||
- CHAT_RUN_LEASE_SECONDS=${CHAT_RUN_LEASE_SECONDS:-30}
|
||||
- CHAT_RUN_MAX_SECONDS=${CHAT_RUN_MAX_SECONDS:-1800}
|
||||
- CHAT_RUN_DRAIN_SECONDS=${CHAT_RUN_DRAIN_SECONDS:-1800}
|
||||
- OBSIDIAN_SYNC_INTERVAL=${OBSIDIAN_SYNC_INTERVAL:-60}
|
||||
- OIDC_AUTHORIZATION_ENDPOINT=${OIDC_AUTHORIZATION_ENDPOINT:-}
|
||||
- OIDC_TOKEN_ENDPOINT=${OIDC_TOKEN_ENDPOINT:-}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Reconnectable chat runs
|
||||
|
||||
A generation belongs to a durable, authenticated run rather than an HTTP connection. Closing a tab, navigating away, or losing the network detaches its subscriber. Explicit Stop sends cancellation to the run. Generation calls the authoritative `runtime.prepare_agent_turn` and `runtime.stream_prepared` under its own application context, without retaining the request context.
|
||||
|
||||
## Wiring
|
||||
|
||||
- `blueprints.conversation.run_models` is registered in `config/db.py`; the tables come from migration `20_20261012231017_application_foundation.py`.
|
||||
- `app.py` registers `runs_blueprint`, calls `expire_runs()` after ORM start-up (only already-expired leases; another deployment may still own live runs) and `drain_runs(app)` right after the serving lifecycle's `yield`, while the agent and checkpointer are still open.
|
||||
- `python app.py` serves through Hypercorn with `shutdown_timeout = CHAT_RUN_DRAIN_SECONDS + 30`, so lifespan shutdown outlasts the drain. `scripts/deploy.sh` gives the old container `CHAT_RUN_DRAIN_SECONDS + 60` (override with `DEPLOY_STOP_SECONDS`).
|
||||
- Every writer to a conversation checkpoint is serialized: `/runs` and `/stream-query` through `ChatRun.active_key` (cross-process), `/query` returns 409 while a run is active, and all channels share `runtime.thread_lock` (in-process).
|
||||
- Streamed deltas are coalesced (`FLUSH_SECONDS`, 150 ms) before they are written, and same-process subscribers are woken on each write; other processes fall back to polling every `POLL_SECONDS`.
|
||||
|
||||
## API
|
||||
|
||||
All endpoints require the existing JWT authentication. Run creation, discovery, replay and cancellation check both owner and conversation. Attachment keys must be under the authorized conversation's storage prefix.
|
||||
|
||||
- `POST /api/conversation/runs`: `{conversation_id, query, request_id, image_key?, file_key?}`. Returns a run (`201` new, `200` idempotent replay). Clients must retain the same request ID for ambiguous retries. A reused ID with different input returns 409; another active run in the conversation returns a clear 409 before saving another user message or starting generation. Legacy callers may omit request ID and receive a fresh one.
|
||||
- `GET /api/conversation/{conversation_id}/runs`: `{run: ... | null}`. Returns the latest run, including terminal status, so loading a conversation can discover active generation or an interrupted attempt. Includes original query and image key for the brief interval before the user message is saved.
|
||||
- `GET /api/conversation/{conversation_id}/runs/{run_id}/events?cursor=N`: ordered SSE replay followed by live database polling. Only events with sequence greater than N are returned. `Last-Event-ID` is also supported when no query cursor is supplied. Negative, noninteger and ahead-of-log cursors return 400. Event `id:` is the durable sequence; event payload types remain `thinking`, `thinking_delta`, `tool_start`, `tool_end`, `content`, `response`, `error`, and terminal `[DONE]`. Comment heartbeats do not advance the cursor.
|
||||
- `POST /api/conversation/{conversation_id}/runs/{run_id}/cancel`: requests cancellation; it is idempotent. The database flag reaches the owning process through its heartbeat, normally within one second. Completion may win a race with cancellation. Keep reading until the terminal event.
|
||||
- `POST /api/conversation/stream-query`: compatibility adapter creating a run and attaching at cursor zero. Existing event types and `[DONE]` remain; `X-Chat-Run-ID` identifies the run. A disconnect detaches this endpoint too.
|
||||
|
||||
Run statuses are `running`, `completed`, `failed`, `cancelled`, and `interrupted`. Terminal errors precede `[DONE]`. A model stall retains its existing `stalled: true` event and metrics. Transient subscriber failures are recoverable and are distinct from generation failure.
|
||||
|
||||
## Storage and lifecycle
|
||||
|
||||
`chat_runs` stores the authenticated owner, input, idempotency key, claim token, lease, cancellation flag, partial text and persistence markers. Its nullable unique `active_key` holds a conversation until terminalization. A composite uniqueness constraint makes duplicate creation safe across processes. `chat_run_events` has unique `(run_id, sequence)` ordering. Database transactions lock the run before appending events, finalizing or saving messages; answer and user persistence markers are committed with their messages. Tool messages/artifacts and the existing streaming metrics/timeouts stay on the established streaming path.
|
||||
|
||||
PostgreSQL is the deployment store; real file-backed SQLite exercises the same ORM lifecycle in tests. Subscribers can land on either deployment process because replay reads shared storage. The owning process renews its lease, checks cross-process cancellation, and cancels itself if renewal fails. Expired runs save available partial text once, release admission and emit an interrupted error. A process restart **does not restart generation**. Prior persisted events and partial answers remain available; the user starts a new turn explicitly. Lease expiry assumes failed renewal promptly stops the worker; it is not fencing for independent checkpoint writers.
|
||||
|
||||
Defaults, read from environment at module load:
|
||||
|
||||
| Setting | Default | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `CHAT_RUN_LEASE_SECONDS` | 30 | Abandoned-worker lease; minimum 10 seconds |
|
||||
| `CHAT_RUN_MAX_SECONDS` | 1800 | Entire generation, including preparation and tools |
|
||||
| `CHAT_RUN_DRAIN_SECONDS` | 1800 | Wait for tracked generations before interrupting on shutdown |
|
||||
| `DEPLOY_STOP_SECONDS` | drain + 60 | Deployment container stop grace |
|
||||
| `TTFT_TIMEOUT_SECONDS` | existing 30 | Existing per-model silent-output timeout |
|
||||
|
||||
Subscribers have finite lifetimes. The browser retries with its last successfully handled cursor, bounds retries after consecutive transport failures, and bounds silent reads. A fresh conversation load rebuilds the active turn from cursor zero after excluding saved tool/assistant rows from that turn, avoiding double-rendered output. Unmount and route switching abort only the reader. Stop uses the cancellation endpoint, including when pressed during creation.
|
||||
|
||||
## Validation
|
||||
|
||||
```sh
|
||||
.venv/bin/python -m pytest tests/unit/test_chat_runs.py tests/unit/test_agent_runtime.py -q
|
||||
cd raggr-frontend
|
||||
node --test tests/runs*.test.cjs
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
Tests cover real SQLite creation/claim contention, owner/conversation isolation and JWT endpoints, replay order/cursor, once-only answer persistence, request idempotency, application context, disconnect versus cancellation, file database close/reopen and lease expiry, drain, the actual shared runtime and legacy endpoint, SSE framing/duplicate suppression, authenticated discovery/cancel and cursor retry.
|
||||
|
||||
The stall/metrics tests run through the durable worker: persistence happens in `runs.py`, and request cancellation detaches the subscriber rather than stopping generation.
|
||||
@@ -0,0 +1,103 @@
|
||||
# Scheduled and cron job delivery
|
||||
|
||||
Priority2 uses durable delivery claims and attempt history. Apply migration 20
|
||||
(`migrations/models/20_20261012231017_application_foundation.py`) before running the
|
||||
updated backend. It adds `job_deliveries` and `job_attempts`, unique delivery
|
||||
identity `(kind, job_id, run_id)`, claim tokens/timestamps, scheduled-message
|
||||
`series_id`, unique `previous_occurrence_id`, `deleted_at`, and nullable cron
|
||||
`created_by_id`. Migration 20 does not assign owners to existing cron jobs.
|
||||
Downgrading drops delivery history and ownership/series fields.
|
||||
|
||||
## Scheduled messages
|
||||
|
||||
The panel preserves creation, cancellation and deletion. It polls the message
|
||||
list every ten seconds and exposes list/action errors, including permission or
|
||||
claim conflicts. Delivery history is loaded on demand with authenticated
|
||||
`GET /api/scheduled-messages/<id>/deliveries`. Refresh history to see later changes;
|
||||
a retry refreshes both the list and any open history, even when it fails.
|
||||
History includes each delivery's run ID, status/error, and every attempt's status,
|
||||
error, start time and finish time. An unfinished attempt is explicitly labelled.
|
||||
|
||||
| Status | Meaning / action |
|
||||
| --- | --- |
|
||||
| `pending` | Waiting for its scheduled time; cancel or delete is available. |
|
||||
| `processing` | A worker claimed the delivery; retry is unavailable. |
|
||||
| `sending` | Attempt/history status: provider call is underway. |
|
||||
| `sent` | Provider acknowledged delivery; retry is unavailable. |
|
||||
| `failed` | Failure known to be safe to retry; explicit retry is available only when `retryable: true`. |
|
||||
| `uncertain` | Delivery might already have happened. Reconcile with provider records or the recipient before considering any resend. |
|
||||
| `cancelled` | Future work cancelled; delete is available. |
|
||||
|
||||
Retry uses authenticated `POST /api/scheduled-messages/<id>/retry` with no schedule
|
||||
or status mutation. It executes the existing delivery identity and records a new
|
||||
attempt. A retry can return HTTP 409 with a changed message status (including
|
||||
`uncertain`); the UI surfaces the error and reloads the state. Editing a failed
|
||||
message's scheduled time does not retry it. The frontend update service accepts
|
||||
only `cancelled` as a status mutation and rejects `pending` at runtime, so updating
|
||||
status cannot bypass the explicit retry endpoint.
|
||||
|
||||
Uncertain outcomes include ambiguous provider failures and abandoned claims.
|
||||
They are never automatically retried. This panel has no reconciliation or force
|
||||
resend action. Resolve the evidence through an operational reconciliation process;
|
||||
do not create a replacement message or new run ID simply to bypass uncertainty.
|
||||
|
||||
Recurring occurrences have separate rows and delivery identities, linked by
|
||||
`series_id` and `previous_occurrence_id`. Cancelling a recurring occurrence cancels
|
||||
the series' future work. The panel labels this action **Cancel series**, including
|
||||
on completed or uncertain occurrences where future work must be stopped. Already
|
||||
started provider calls may still complete; cancellation cannot recall a delivered
|
||||
message, and attempt history remains evidence of the outcome.
|
||||
Deleting a pending/cancelled recurring occurrence uses the backend's soft delete
|
||||
and durable cancellation tombstone: the selected occurrence is hidden, and future
|
||||
series work is cancelled so recurrence repair cannot revive it. Other cancelled
|
||||
occurrences can remain visible. Nonrecurring pending/cancelled rows use the existing
|
||||
delete API. The UI never simulates cancellation/deletion by altering local series
|
||||
rows; it reloads the backend result.
|
||||
|
||||
## Cron callers and ownership
|
||||
|
||||
`POST /api/cron/<id>/trigger` requires `X-Cron-Secret` and a nonblank run identifier,
|
||||
maximum 255 characters. Supply either a JSON `run_id` or an `Idempotency-Key` header;
|
||||
the header takes precedence. Choose a stable identifier for each logical execution
|
||||
(for example the cron occurrence's UTC timestamp), and reuse it across repeated
|
||||
trigger requests for that execution. A new logical occurrence needs a new ID.
|
||||
Repeating a trigger with the same ID returns the existing outcome without resending;
|
||||
it does not retry a failed or uncertain delivery.
|
||||
|
||||
```http
|
||||
POST /api/cron/<id>/trigger
|
||||
X-Cron-Secret: <configured secret>
|
||||
Idempotency-Key: daily-report:2026-10-13T12:00:00Z
|
||||
Content-Type: application/json
|
||||
|
||||
{}
|
||||
```
|
||||
|
||||
Legacy cron jobs require an explicitly assigned existing user. An administrator
|
||||
must use authenticated `PUT /api/cron/<id>` with
|
||||
`{"created_by":"<existing-user-uuid>"}` after reviewing the correct owner. Execution
|
||||
fails safely until ownership is assigned; it never selects an arbitrary admin.
|
||||
New jobs get the authenticated creator as owner. Agent execution uses that owner's
|
||||
context and permissions. Assigning ownership does not itself retry an earlier
|
||||
failed execution.
|
||||
|
||||
Authenticated `GET /api/cron/<id>/deliveries` returns the same history structure.
|
||||
For a safe failed execution only, an administrator can use authenticated
|
||||
`POST /api/cron/<id>/retry` with `{"run_id":"<original-run-id>"}`. Reuse the failed
|
||||
run's ID; do not invent an ID to evade delivery claims. Processing, sending, sent,
|
||||
cancelled and uncertain executions are ineligible. Uncertain cron deliveries need
|
||||
the same reconciliation as scheduled messages. Database claims prevent duplicate
|
||||
workers from winning the same delivery; they cannot prove exactly-once external
|
||||
delivery when a provider acknowledgement is lost.
|
||||
|
||||
## Frontend validation
|
||||
|
||||
No additional packages are required. Tests use Node's test runner, the installed
|
||||
TypeScript transpiler and React's static renderer, following the existing renderer
|
||||
harness in `raggr-frontend/tests/history-management.test.cjs`.
|
||||
|
||||
```sh
|
||||
cd raggr-frontend
|
||||
node --test tests/job-delivery*.cjs
|
||||
./node_modules/.bin/tsc --noEmit --target ES2020 --lib DOM,ES2020
|
||||
```
|
||||
+14
@@ -9,6 +9,20 @@ from prometheus_client import (
|
||||
|
||||
REGISTRY = CollectorRegistry(auto_describe=True)
|
||||
|
||||
AGENT_REQUESTS_TOTAL = Counter(
|
||||
"raggr_agent_requests_total",
|
||||
"Agent generation requests by channel and outcome.",
|
||||
labelnames=("channel", "result"),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
AGENT_RESPONSE_SECONDS = Histogram(
|
||||
"raggr_agent_response_seconds",
|
||||
"Agent generation time by channel, excluding delivery.",
|
||||
labelnames=("channel",),
|
||||
buckets=(0.5, 1, 2, 5, 10, 30, 60, 120, 300),
|
||||
registry=REGISTRY,
|
||||
)
|
||||
|
||||
CHAT_RESPONSE_SECONDS = Histogram(
|
||||
"raggr_chat_response_seconds",
|
||||
"End-to-end chat response time in seconds.",
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
from tortoise import BaseDBAsyncClient
|
||||
|
||||
RUN_IN_TRANSACTION = True
|
||||
|
||||
|
||||
async def upgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
CREATE TABLE IF NOT EXISTS "chat_runs" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"request_key" VARCHAR(128) NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"active_key" VARCHAR(36) UNIQUE,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'running',
|
||||
"worker_token" UUID NOT NULL,
|
||||
"lease_until" TIMESTAMPTZ NOT NULL,
|
||||
"cancel_requested" BOOL NOT NULL DEFAULT False,
|
||||
"sequence" INT NOT NULL DEFAULT 0,
|
||||
"partial" TEXT NOT NULL,
|
||||
"user_saved" BOOL NOT NULL DEFAULT False,
|
||||
"answer_saved" BOOL NOT NULL DEFAULT False,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"conversation_id" UUID NOT NULL REFERENCES "conversations" ("id") ON DELETE CASCADE,
|
||||
"owner_id" UUID NOT NULL REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "uid_chat_runs_owner_i_98309f" UNIQUE ("owner_id", "conversation_id", "request_key")
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idx_chat_runs_lease_u_b8a59a" ON "chat_runs" ("lease_until");
|
||||
CREATE TABLE IF NOT EXISTS "chat_run_events" (
|
||||
"id" BIGSERIAL NOT NULL PRIMARY KEY,
|
||||
"sequence" INT NOT NULL,
|
||||
"payload" JSONB NOT NULL,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"run_id" UUID NOT NULL REFERENCES "chat_runs" ("id") ON DELETE CASCADE,
|
||||
CONSTRAINT "uid_chat_run_ev_run_id_1326b1" UNIQUE ("run_id", "sequence")
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS "job_deliveries" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"kind" VARCHAR(20) NOT NULL,
|
||||
"job_id" VARCHAR(36) NOT NULL,
|
||||
"run_id" VARCHAR(255) NOT NULL,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'pending',
|
||||
"claim_token" UUID,
|
||||
"claimed_at" TIMESTAMPTZ,
|
||||
"error" TEXT,
|
||||
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "uid_job_deliver_kind_1add26" UNIQUE ("kind", "job_id", "run_id")
|
||||
);
|
||||
COMMENT ON TABLE "job_deliveries" IS 'Durable run identity; never reclaim ambiguous external deliveries.';
|
||||
CREATE TABLE IF NOT EXISTS "job_attempts" (
|
||||
"id" UUID NOT NULL PRIMARY KEY,
|
||||
"claim_token" UUID NOT NULL UNIQUE,
|
||||
"status" VARCHAR(20) NOT NULL DEFAULT 'processing',
|
||||
"error" TEXT,
|
||||
"started_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"finished_at" TIMESTAMPTZ,
|
||||
"delivery_id" UUID NOT NULL REFERENCES "job_deliveries" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "conversations" ADD "pinned" BOOL NOT NULL DEFAULT False;
|
||||
ALTER TABLE "scheduled_messages" ADD "deleted_at" TIMESTAMPTZ;
|
||||
ALTER TABLE "scheduled_messages" ADD "previous_occurrence_id" UUID UNIQUE;
|
||||
ALTER TABLE "scheduled_messages" ADD "series_id" UUID;
|
||||
COMMENT ON COLUMN "scheduled_messages"."status" IS 'PENDING: pending
|
||||
PROCESSING: processing
|
||||
UNCERTAIN: uncertain
|
||||
SENT: sent
|
||||
FAILED: failed
|
||||
CANCELLED: cancelled';
|
||||
ALTER TABLE "cron_jobs" ADD "created_by_id" UUID;
|
||||
ALTER TABLE "scheduled_messages" ADD CONSTRAINT "fk_schedule_schedule_31982f9c" FOREIGN KEY ("previous_occurrence_id") REFERENCES "scheduled_messages" ("id") ON DELETE SET NULL;
|
||||
ALTER TABLE "cron_jobs" ADD CONSTRAINT "fk_cron_job_users_b3e69ad6" FOREIGN KEY ("created_by_id") REFERENCES "users" ("id") ON DELETE SET NULL;"""
|
||||
|
||||
|
||||
async def downgrade(db: BaseDBAsyncClient) -> str:
|
||||
return """
|
||||
ALTER TABLE "scheduled_messages" DROP CONSTRAINT IF EXISTS "fk_schedule_schedule_31982f9c";
|
||||
ALTER TABLE "cron_jobs" DROP CONSTRAINT IF EXISTS "fk_cron_job_users_b3e69ad6";
|
||||
ALTER TABLE "cron_jobs" DROP COLUMN "created_by_id";
|
||||
ALTER TABLE "conversations" DROP COLUMN "pinned";
|
||||
ALTER TABLE "scheduled_messages" DROP COLUMN "deleted_at";
|
||||
ALTER TABLE "scheduled_messages" DROP COLUMN "previous_occurrence_id";
|
||||
ALTER TABLE "scheduled_messages" DROP COLUMN "series_id";
|
||||
COMMENT ON COLUMN "scheduled_messages"."status" IS 'PENDING: pending
|
||||
SENT: sent
|
||||
FAILED: failed
|
||||
CANCELLED: cancelled';
|
||||
DROP TABLE IF EXISTS "chat_runs";
|
||||
DROP TABLE IF EXISTS "job_attempts";
|
||||
DROP TABLE IF EXISTS "chat_run_events";
|
||||
DROP TABLE IF EXISTS "job_deliveries";"""
|
||||
|
||||
|
||||
MODELS_STATE = (
|
||||
"eJztXQtv4za2/iuEscBOATc3iScz0+zFBZyMO007eSDx3G63KTSyTNtsZMqrR1Kj2/++JC"
|
||||
"VZL1KWZEkWHQLzSCQeSvr4Ouc7h4d/9pbWFJrO0aWFn6Ht6C6ycO8c/NnD+hKSH7j3+6Cn"
|
||||
"r1bRXXrB1ScmEzBiJdkdfeK4tm645OZMNx1ILk2hY9hoFTwMe6ZJL1oGKYjwPLrkYfRvD2"
|
||||
"quNYfuAtrkxq+/kcsIT+Ef0KG//trzHGhraEqf5K2mugunmu72fqPlVk/aDEFzmvggvyi7"
|
||||
"rrnrFbv25cvVx+9ZSfoeE82wTG+Jo9Krtbuw8Ka456HpEZWh9+YQQ5s+NvZ99PUDPMJL/q"
|
||||
"eQC67twc03TKMLUzjTPZOi1PvfmYcNCg5gT6L/vP2/XgncSBtQzBF2KUh//uV/VfTN7GqP"
|
||||
"Puryh+H9m8G7b9hXWo47t9lNhkjvLyaou7ovygCPgGT/Z6C8XOg2H8qwfApM8qJVYAwvRD"
|
||||
"hGnSsEMgSoGmq9pf6HZkI8dxfk19OzsxwY/394z5AkpRiUFunw/nC4CW6d+vcopBGEhg3D"
|
||||
"DpsB8iO546Il5IOZlExBOg1Ej8IfOgow+YbpLTbXwSDIwXd8dT16GA+v7+iXLB3n3yaDaD"
|
||||
"ge0Tun7Oo6dfXNu1RTbCoBP1+NfwD0V/Cv25tRuu9vyo3/1aPvpHuupWHrRdOnsfEaXg2B"
|
||||
"STRsbCYq2bBJSdWwe23Y4OVjA3ahYwzNMtNeTKTSzBe8Vanm673AyQ7rRWrmOy4y8R2L57"
|
||||
"3j9LS3QgQRzjJ8YVkm1DEfx0goBeOESDU1DsoqLMUX3ovb28+JPn9xNU4h+OX6YnT/5oQB"
|
||||
"Swohl12+uhmn4IxpQEXVmpjIDrpNla6528xSXZOh+uDsiavIUDCy4H1v2RDN8U9wzSC8Iu"
|
||||
"+hY4OnwQQq8pegmq6CFl2N3sLWXzYqcrxTkK8j3wT9Hnc5fLgcfhz1GIYT3Xh60e2pJgBz"
|
||||
"CR1Hn0OHM7oDye9/uofmxpDgYxk3N679Gru51ImwTa8armZ7eFdQSDX3HpYYCN120YyYgj"
|
||||
"sCMQyqkQwJOnysUys2bBIDKntrebpMX9ExGQrT4Nn0STkDZosZHxtXxax5LT62G7bqE8/1"
|
||||
"56SYyaOs+8atexf+wbFdxuQqH8qwvCzWfZ5NMvrnOKGahbrsm+vhP79JmCSfb28+hcVjmt"
|
||||
"vl59sLZeofokWYNfWdFdSfeAokNQlH2Ftm1MdE68bE9zx0el8eRvfngGqBj/jh6vpieA4c"
|
||||
"tJzoj3hMjBWCRmBolTUgT4oYkCdiA/IkY0CiJVmGtCe4LmOIJ4RaM8Xr5CDPTk4LIElKCa"
|
||||
"Fk95JYBou6ttDx1CxF6GYlpUS1GWY3q78UVUo4onVqKJIa6UbKAbWjsZ72Z3UWxa1WO6e7"
|
||||
"VLfe2zfQukOHNGqfUXLoGi4te93jmGWxu/08a4wxNEtaELVghSkjq2kjizzEhbiUnRUTUa"
|
||||
"aWMrU4ys3hmlrKq3oQDZuhhffmwCrQipmlpYO6cbsOrL2B1qQDq1FuPvDb8Pj4yKWTw8HH"
|
||||
"3Uf1any/9qwX7Ld62sCyISnsuIww+U1phi1rhnH0M4iKKZmUmCwaYoovPP1QhDA8/SBmDO"
|
||||
"m9VMyJvjYtndM9f3y4vRFEnEQiaY0BGS74DzCR020fJA8/+r35Kndau071XlpBWuUmExJ6"
|
||||
"Ls3IJqVqIQ+3TwB19tTBuwIddZBWrKJ+Sm8lgSRrtOtx2BcxiJFEe6O9R1YjTGGqC8j6Y8"
|
||||
"xeLPuJaAOu9QQ5fKF4TUrLvRbqNY6dCXUHah52ESfSMd/QSom2Y2m1p6N3ybAKccg1mQ2q"
|
||||
"8ptaoBeUDrrkiavwy9SUTcEJDKsktFdYQOLFRVJwItyYSnG8w2zNwom+PT15+/7th8G7tx"
|
||||
"/6dKjTiTu88j4H7CxmK+po0Dmzi5j4jIm0uNA1Yxg0Qnsy69fRn0sP8qSgGt4p1RY7LxWB"
|
||||
"TYsqaBVRf4B8riLqD7Rhs4HsKqplR9OK0a0loYvLvBbMVCRQg5FAmQ7ZKefRvuCLD7PqEV"
|
||||
"TwGeJdw6cCr9DoGTZnDEoYQ5WARexM28C23aOmRa1Vt1/N9r16G0t/JxfaBZoLuQTuusBh"
|
||||
"EYIuv5v3bEfFyGcRvjs9HQzenx4P3n04e/v+/dmH4w2dkL2VxytcXH2iRkVCGQqtDLFrTR"
|
||||
"q+phawa6RslAOtOQeaMoUPwmLKmsJ0lSmn7kcSStkPl9FddXwZd06n9dOoX3QpuOn26uPl"
|
||||
"g6uzd8loZNHNXHXMQlNDo37sgkHtvTuIpwRSQOsHpjVH+Bws9ZUDiP4FvrKavoKVbutL4F"
|
||||
"oAuQ64++lyBIh1hMhn2Ue9VMPsXOEjfsQPLumWU4AwuAu6OHiDLResbMuAjgNY3P76G+BY"
|
||||
"QPefQWrXXUBqt0mFFiZ/4COemB78HyINMSBzgmUDHU/BDGHkLCArRV/JooomkUSmCZ51E9"
|
||||
"Hp3WGftUNYvxO2VJnQgxpTerUbu1FjlJFY11w9GVALe0kZaDOCKpQrMrH/WCEyuiroSklJ"
|
||||
"FZyw7+AEpfQejtKbUeL2o48wHlKwxS5fC6FucLWpTv7QadqOZXOTxmXkXGkb2cW+0h3nxb"
|
||||
"K5jCTW7bWI9omk0iEP60DH7+quYC7NGHA4m4mXQHZxdTO8/4UfrnPB4XwufhmPhmk9Zqnz"
|
||||
"AizFvXQjIKW+fVwsDUheHpBslC8xoBwypWvYW07KadgcURkj0IuAOhBjOuDkVgnSepSHlC"
|
||||
"OqIA3HrQYx/byyMWQZWRVExgN3sdQNUbD/lgk1JSthjz0psg3lRLwN5SSzDcVn5LzJ79Dg"
|
||||
"2GZiQNNyEoLZiCJFrKWFtrKtZzQtN6VmBFuMfTYtw4+3rid5VZFp9Uw8rZ5lplVzqq+0uW"
|
||||
"15K068hdgrmRJr3zPZ+/W3Zowm5ZlUJI0K0n1lDbsJuypIvvEDKWvM191l8z4/PXU8s1Z1"
|
||||
"LJLJvLrX8wtBoVKWJ8wL3TAsb+e4zhGta+hXJTEkjrGAU4+Yo1o9yf4fwvoOINO/TT7vd2"
|
||||
"uy67Ah1fxoTeSdSSfQRNRsmSFz195xQaq682uSuGOokw+acvixSbXH8fiNQrZc7PJjk3vB"
|
||||
"mCNWHQhmPPK/q1MPFIvOCXIyZsOKCshsIoccGl/kwClgLwVYwRfkLsDg+NupvgY2pOXJyx"
|
||||
"yBzwg/OTQQ6XJhW0v948Ujnlk2eIYGqQg4ULeNBTD0lT5BJnLR7pFByjdZk28yJMh5gG5P"
|
||||
"tM0FVgrnTyOkWgV+cldqcu9u3rNCXrSzHC/aWdaLNiOzCLUvST8ulXooLScnpI30TWKeCw"
|
||||
"HNOUTFkhHOthNdTLlhqvmUVSgjJ1klCTlVKOhvYk3XWtnThRJCkpwy0fawYBgt3GWprDoJ"
|
||||
"IQUsP58407LJE8mrldTcOKKSgKxOv1YOiKqeJbkC9NViLW7JgPcuuYMxKaV2MYaAZDEsvZ"
|
||||
"NRXhdCP7WdMdlJurSlMYGxiFiMtcEWfjHhPCrKMwYylCacoblnM14YUJbv6np4Ry9jyOgu"
|
||||
"R8Q6lqghxkFupGw4pXQjeUefhYTYsNcr0tFAGN7dj9UBHOi65PFO/xFTdjOsxk9OfAQevN"
|
||||
"XKorscl6SLoZUJwxIOWEGbnTmoOMqOcJRBpy1PB2UEFVMZMhbIWZn6Wiu7LSUtp2yH2Amg"
|
||||
"K21hOaXY34SQLOxaW2DS+TkLpjjtUFymvZw433032AHKmlPiMAyqbDfLCKrOmOqMwo1no1"
|
||||
"APEXNamRpkQbdtYgsRQ5sdwZGFOXdPSkKuxf0om4W/w9tRTN1xNWjbFifMX9xlk1KSLPOt"
|
||||
"87CKLzxMvlBFoh9Ew0p1rqMUvGHnDnbcF1m4w8mOKWKjjhBuubBMxkytsZ9qjXfIWGkgHk"
|
||||
"htD5vKJIKkcfo4hoyIQU6Ct41ETjXcdh55TG4/OYDK0RR0dNJxWOQpy9rGaFcYJ4qzPHLp"
|
||||
"GiiPfE1hJn8dQHVaX5iuvURmuer7F4OMeHC6CY0lM2HAHM9IhZ4NAf26J5qSz7WA47PHAB"
|
||||
"HbDy4hqd/0K6YCTGsGC/KjSYorFrkjLDKzaFinrRKulZWuQbfrloXUIVWukCuYNUkYiezx"
|
||||
"xomQK+SJvspjz0IMBN5gIYAZuVeJHq0eGh47MzZYJjhKjBBEkfirxJINyQCF6jN0ugI1Se"
|
||||
"95klZMymEyKSoO68DisJoOapA/CutHazJ0XbhccWOwYnf7ecbz79aETGmsoMrtKr9VaZg6"
|
||||
"WpY/1D0l1i60+5oHE7SfgPHLz+3vtbwNrRcQQxSPHbSAZIxCkf2Sp+LtkqfZbI5lnbvKr5"
|
||||
"vv12UHYFRSWpOSSmntmF83OLWkSsumRJVhuWfDkihW6Bna65IWSEpMmSAbRGqwQYgO/DFW"
|
||||
"W2cB3GqEpHpJx6yQDcZ8MyTeBPl2SPCZqOgpVx89m4oD28MAsd0Y7vofAENSB7Ah02mBvp"
|
||||
"yguWd5DiDqB40nNUH0mKxLr54quQeePpF+RG/SL/XbMTiwbKeTT5WVVMFKCpuiqK4flpcl"
|
||||
"TrZpDT/qwkURjCTkxHBQJIP4QJxBfJDJIC4691KMofDcS0kwbCbHkSSGu39+Yoet9r0zR5"
|
||||
"2yP0pQRwyBalHXCUllwu3ZhFO8ldqPUOfid7i8lfKiH0TDVs+MH/cYVg+RTvoou9fQe4mO"
|
||||
"ziT75rAqvITgYmqFn45cOXqlpjB8ArDKFJyUVFrnnrVOh9GGJd0GCaFXaHPZ0EArBHmRSj"
|
||||
"n8SVxIUSjxo0wwhoKDVkfYW2bcLakUjhvxPYPao8PxYfhpdA7CkzYf8eh6ePX5HGyOhu0A"
|
||||
"0+Jnti9jacZEZOm4rQdJtJ/s/CATB0XaYoVwk5SsnPbdQWkXOfT49ql9L1R5YNP4Oz+P7k"
|
||||
"Y3H69uPmX1715w5xwE3Pojvru/vSQLgH9xEyn3iL/cXI7ux8Orm3NAVHto0+2Xj/hhdDM+"
|
||||
"Bw6ZVh/x92SNGH2kr4NI733El0Mi8pldMigyZnC28P4XD6LEeLYNg9aq0qjJGlps2PvNg4"
|
||||
"9ugu6ealJ6mQBrYbJufyRN8ss5mJIWWT/in0ejn+ivLxA+0d+vb2/GP9ALS7IyLsx1NxrH"
|
||||
"c6BGui5vbc/NF5SQU+dXc8JJQ+qiND0fF5RkkVc0vWJzFU2vGla42S0cdpOy0aYZwdcSbx"
|
||||
"pHb2XDZ2R5jmYZoT5SEkZxDTVScd3dN5MTvBt1sCyerzArU2a87R65m9uPs6DfYji2yD8F"
|
||||
"Ia9w0uye+u1W7MWDNNEID6MxuPny+XOqFUSgY6Jt5gIe+j9D4Bs53bdDh9qW9G6GB/dynJ"
|
||||
"qxM33FvszE+cHKhSm1C7NsUmiVCzqbNz/2ZhkkxZZxSkzZxVy7eGVbQWhKUVwjCVn6aNuY"
|
||||
"KsetctxK67iFmH45Z/nPJXdjUioVfAJPliKL7mopT1ClRFUUUxeSH5bfi5MSk0QRSZ84Xm"
|
||||
"CeOcs7bzw9z6gjEhrTP5Sv4yAoceXrONCG7aavo1PzolzsfHeoyn4pcl7AC7eWV+MCmujO"
|
||||
"tmaIoZghS+O3+3mE6YQU1FZ+yQZI0183R3CwB/nGmkpo0TaVGgO/hPadlFLEShLOnY7Yy9"
|
||||
"QgC7ptK+QMKOE5SFv6rvgwpK7aO211XhvOyOyxEOVzKNqDM9VIAnPbvdino3ywKhkh3AoU"
|
||||
"q9UFVksRMU2MGMWl18ulK2LrIPgPRWwdaMOqsz/V2Z9dPvvTpgcQeraOd03qQpmp+01lci"
|
||||
"HbaGKXFDACUi8J3RZeL9VodVN7q4hgZI+beA7C0AnzTsTcvTaCeKqlbXL2u0Fafm7Za8UK"
|
||||
"7ocVTDVaKX4lJSsLh9UCySLo8UXRFYi3uBl3h87aHrxlI4RTYpKY3m1xgpuZuOw0EBeUsY"
|
||||
"ueFInQORFH6JxkInReV+j62XGxEKe8GKdsFhzkluqJYXk1qKO9SGi+mFj2wrJKLT5pOYXo"
|
||||
"pk96iCo8WTB/fLi9EXTLSCTNsCDDBf8BJnIas4JieujEQ6aLsHNEn9eQKkpRyCeC05xvSm"
|
||||
"elFWR3WSADasSG4SV3EHfilJiUfbj+dUm+dD/8/QAXo9HNOZhAiB/xz8ObsTa+1cb3NP8L"
|
||||
"sXWJlaq5dkdSvpAe+KSxD8yiLjxpOylU6XztPfTdmk/YdgzL5p2qDQ201E0+cBuZ9FzrCx"
|
||||
"0Fwp3ELwecj6PLq+vh5zdn/dOUoyfsk2+zKqjl8k53F7slNwKSzJVteyRdfc7BU7zyh+XV"
|
||||
"sl9l2Z9B16h48mFCUrm1uuXWUn7og2jXrB86cAaUTpUTl1Jey7hPZUfHZSoEvLP4FcjUEu"
|
||||
"8jXTrccmi7aOY71DLOus29fp6bTg9KFTzRcghICc9wPRtOgWtZJiAdi7QFeMP2wBOJiTed"
|
||||
"Q7cPqDb36J0en777hpQhbUglEAbuAoLwoWClY2hmD7ls7Ckqq0o780s/x+nnBoFwRYmUsL"
|
||||
"yc1HQjNODrOh703dsCCL57KwSQ3kpZUsjlrXA5fTAUkBPBZlL7kAFexhwNy7dvjjamZTVi"
|
||||
"eDqWZ1PmGM7KoJuU2gnjTjEpjUD8DG2Hm5FKSIjGJCqxoZX67ckO479mLlRZzQdqNavo7Y"
|
||||
"No2CzNZWE6Z7FY2LKJCbKizfIinUkcHAcwSPNVErukVKMZHTqJmto0oDYNtLRpQDTb1YDe"
|
||||
"Zaq6rk5x21OIZKfy7WAKTzLZCcc6k0fvCc3k5N6pbCy/YH0yNAzL42/ciN/u59HBa1JQ0/"
|
||||
"2SHUthLWZn6oxNbzo7fJKYGbwrwMsM0lpkRMvQWyqHdcNEF8OgBIRheTkhrD9bJMHAd9dk"
|
||||
"QczdS5+Qa3E3fWNaYZ276U3LKZ2cIBJSaGZC1bJY5keqVR7hnaJXm0mZpJs69+yR3NDJmN"
|
||||
"TOwZPd6qlh9OTJWX9QOHyS4KvbNPtfJTA50gpU+uq7wcqVV8Bu0kORpxio2tHM/BpUhqk9"
|
||||
"Z5jyTdyymkZMSp0Wy+H79kcSXIZbZQUsweZ+fytNEOy6RU2kbVVEQdtEQbiHWpvblrcqmS"
|
||||
"6AK9yU/SsF0IqBqZGBWaDplJclNHcNioSUtZtAkzwN2ljnBGHm4hkXU4u6IhAaJBDmlm5q"
|
||||
"ZWnXhJAkwDa9+dfHRLe55GuupZuSPMztrKVM3Bgi2tLCpMWyiJLv3wpnJJxn3EoHKTFWM2"
|
||||
"d/KtPxEE3HT1TH722xH/1C/aJGZGA5KEtSGgNHuZyVwdPkgqIMni5Dqtb2A1vbFyxRlWhR"
|
||||
"X4RprLat5qxg0b3EqxXE028tbK6Bac2BNQMvC90FUDcWwFljA/jVTft+FhEwWYMpms1IYw"
|
||||
"F9TtrHcdk+3yWiR1lwdhHXW/8jfsS/3Awv/u6A4d0VWOgOwBZYIMcl6ksfOBYpjBxA/lAh"
|
||||
"9lTqzrKn9MFwilwHvIFH8yOgk4p0x0FzvITYBXOLPnBmW0vwt7fHx8C1wN9oOsIjcOUCh9"
|
||||
"gMRFD332SGbPJO9NWb2M18gebC/U9cfYez9SlQXfaq7vhbn747PR0M3p8eD959OHv7/v3Z"
|
||||
"h+PNHqjsrbzNUBdXn+hwTQzrcEIUK0G0kSr5QROC7eyqqbsBDmVTTdYb6kD7GdraE7lvwi"
|
||||
"kvRjpvEPGk29tNKM+oih/C4yJ3XZoKTInJ6YgqQgYOxGTgIENgBaiUM3QTQlICWX/6AN3g"
|
||||
"7zMRoxhJyGnk1p8IlHwaLJUGZCMgJ4LNpKiO1O0kjDnpgCMRlYghmyWgQybZNXMTCCyy69"
|
||||
"CHsMUgY74Gxao2vsjUNUGKDYrSLqe63Eztr9/l3EzK916r7x1hw+Lx97ne4khIBUOTNYDt"
|
||||
"WuKxo/kbIGJiCkZfaX7mHsKRC2NcTMFI8+dpE6hV7JNZYQUp6WJzqFkzqlxBTu8U8j9psV"
|
||||
"eaV1/5jnoH5Ttitsi2fQXJQv1ihkvZLQa9O2h/yyRBGFcC9CXLZXAEvn4Np7GvX6mXhvmC"
|
||||
"qEOHlDVNBzz2hswpA6ePvawzqdaalUG11aDKnVM2M/N7oUFFbymDqpJBtQnJqrgHRGKuuP"
|
||||
"7dH8oWULZAd2BUO/NrAFHpr4elv97pawhFeqt/c7u+uqLlFNEuzQqtwpcb9EuSp2FnBu0w"
|
||||
"k1xJTVIgLglh37RGqZafw1p+HrwJ6/BBjIhgHUqV2r4gOQmBgvzJZ4ShQ+NVdeCsCGIgVs"
|
||||
"U/WAjqSrchdv/uRDQIogTHAy3N401qqVGti61FOeWc2xM1XJX5PCEpJTnQQCDZMszIWsam"
|
||||
"3QgpY4zm5F1aZbz/YXlJlIm2vf/MjCk5vOMyksDatI62Rwq19ezZTWOpjAllTChjoqgxsc"
|
||||
"bGg6u7Qj4rKlDAhCBlNWdTeC+8lu8IKTnmE0JKm+dp8wFEZcmulJiUs2gjO/Y7sjNsb9FB"
|
||||
"LW8MY9liK++xzEqrPLN7zjPLmmRGPtNfdSq2aboC1axdaFZI98+XMdKTUpIsMm2Y6h3SNM"
|
||||
"fbOetxKcK6NFut/Kid1jDDaLii4XVh+QOPrlOcr+J897+QcE4aKUVURiJyhj3Un2qVrG62"
|
||||
"9VyaSouLqbRtCURnpj6nSPF0R3HXTEpJMuQb75yVqPMaGPN9+2zrZ8w76iGTDEXlIFMOMi"
|
||||
"nARcuVZbt+eG9ptpwrLCWwjYSJhh2uLKxpOSkRbcQLUa2bqv4pSK8UrjZlAc0ISolpIz1U"
|
||||
"BRz0dreSOkIDD6GNDG6KquBOP4/01aMynWF6Dyj/7Y7pGsS87jO0nZLpD2MiktJFTUyFdG"
|
||||
"iU0Xr84nICeFJofT7JWZ9POOuzhV3I49Jz0h9GIir9YcfSH/71XxWPG/0="
|
||||
)
|
||||
@@ -4,6 +4,7 @@ import { AuthProvider } from "./contexts/AuthContext";
|
||||
import { ChatScreen } from "./components/ChatScreen";
|
||||
import { ConversationsPage } from "./components/ConversationsPage";
|
||||
import { LoginScreen } from "./components/LoginScreen";
|
||||
import { SourceViewer } from "./components/SourceViewer";
|
||||
import { useAuthCheck } from "./hooks/useAuthCheck";
|
||||
import catIcon from "./assets/cat.png";
|
||||
|
||||
@@ -32,6 +33,7 @@ const AppContainer = () => {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/conversations" element={<ConversationsPage />} />
|
||||
<Route path="/sources/:sourceId" element={<SourceViewer />} />
|
||||
{/* One ChatScreen instance serves both the home ("/") and per-conversation
|
||||
("/c/:id") routes. Using a single catch-all route keeps that instance
|
||||
mounted across the navigate() that fires when the first message creates
|
||||
|
||||
@@ -24,6 +24,8 @@ export type ArtifactSourceRef = {
|
||||
id?: string;
|
||||
path?: string;
|
||||
url?: string;
|
||||
as_of?: string | null;
|
||||
stale?: boolean;
|
||||
};
|
||||
|
||||
/** Full artifact as returned by `GET /api/artifacts/<id>`. */
|
||||
|
||||
@@ -29,6 +29,24 @@ export type SSEEvent =
|
||||
| { type: "error"; message: string; stalled?: boolean };
|
||||
|
||||
export type SSEEventCallback = (event: SSEEvent) => void;
|
||||
export interface ChatRun {
|
||||
id: string;
|
||||
conversation_id: string;
|
||||
status: "running" | "completed" | "failed" | "cancelled" | "interrupted";
|
||||
cursor: number;
|
||||
created_at: string;
|
||||
query: string;
|
||||
image_key?: string | null;
|
||||
}
|
||||
|
||||
export class RunRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
@@ -224,6 +242,128 @@ class ConversationService {
|
||||
return data.url;
|
||||
}
|
||||
|
||||
async createRun(
|
||||
body: Record<string, string>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ChatRun> {
|
||||
// Retry ambiguous transport failures with the SAME idempotency key.
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
const response = await userService.fetchWithAuth(
|
||||
`${this.conversationBaseUrl}/runs`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new RunRequestError(
|
||||
data.error || "Could not start generation",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
if (
|
||||
signal?.aborted ||
|
||||
error instanceof RunRequestError ||
|
||||
attempt >= 2 ||
|
||||
(error instanceof Error && error.message.includes("Session expired"))
|
||||
)
|
||||
throw error;
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, 500 * (attempt + 1)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async discoverRun(conversationId: string): Promise<ChatRun | null> {
|
||||
const response = await userService.fetchWithAuth(
|
||||
`${this.conversationBaseUrl}/${conversationId}/runs`,
|
||||
);
|
||||
if (!response.ok) throw new Error("Could not discover active generation");
|
||||
return (await response.json()).run;
|
||||
}
|
||||
|
||||
async cancelRun(run: ChatRun): Promise<void> {
|
||||
const response = await userService.fetchWithAuth(
|
||||
`${this.conversationBaseUrl}/${run.conversation_id}/runs/${run.id}/cancel`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error("Could not stop generation. Try Stop again.");
|
||||
}
|
||||
|
||||
async resumeRun(
|
||||
run: ChatRun,
|
||||
onEvent: SSEEventCallback,
|
||||
signal: AbortSignal,
|
||||
onConnection?: (reconnecting: boolean) => void,
|
||||
): Promise<void> {
|
||||
signal = AbortSignal.any([signal, AbortSignal.timeout(35 * 60 * 1000)]);
|
||||
let cursor = 0;
|
||||
let failures = 0;
|
||||
const deadline = Date.now() + 35 * 60 * 1000;
|
||||
while (!signal.aborted && Date.now() < deadline) {
|
||||
try {
|
||||
const response = await userService.fetchWithAuth(
|
||||
`${this.conversationBaseUrl}/${run.conversation_id}/runs/${run.id}/events?cursor=${cursor}`,
|
||||
{ signal },
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new RunRequestError(
|
||||
"Could not resume generation",
|
||||
response.status,
|
||||
);
|
||||
onConnection?.(false);
|
||||
const done = await this._readSSEStream(
|
||||
response,
|
||||
onEvent,
|
||||
(id) => {
|
||||
cursor = id;
|
||||
failures = 0;
|
||||
},
|
||||
cursor,
|
||||
);
|
||||
if (done) return;
|
||||
throw new Error("Connection ended before generation finished");
|
||||
} catch (error) {
|
||||
if (
|
||||
signal.aborted ||
|
||||
error instanceof RunRequestError ||
|
||||
(error instanceof Error && error.message.includes("Session expired"))
|
||||
)
|
||||
throw error;
|
||||
onConnection?.(true);
|
||||
if (++failures >= 8)
|
||||
throw new Error(
|
||||
"Connection lost. Reload this conversation to resume.",
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const aborted = () => {
|
||||
clearTimeout(timer);
|
||||
reject(new DOMException("Detached", "AbortError"));
|
||||
};
|
||||
const timer = setTimeout(
|
||||
() => {
|
||||
signal.removeEventListener("abort", aborted);
|
||||
resolve();
|
||||
},
|
||||
Math.min(500 * 2 ** failures, 5000),
|
||||
);
|
||||
signal.addEventListener("abort", aborted, { once: true });
|
||||
});
|
||||
}
|
||||
}
|
||||
if (signal.aborted) throw new DOMException("Detached", "AbortError");
|
||||
throw new Error(
|
||||
"Connection timed out. Reload this conversation to resume.",
|
||||
);
|
||||
}
|
||||
|
||||
async streamQuery(
|
||||
query: string,
|
||||
conversation_id: string,
|
||||
@@ -259,31 +399,64 @@ class ConversationService {
|
||||
private async _readSSEStream(
|
||||
response: Response,
|
||||
onEvent: SSEEventCallback,
|
||||
): Promise<void> {
|
||||
const reader = response.body!.getReader();
|
||||
onCursor?: (cursor: number) => void,
|
||||
initialCursor = 0,
|
||||
): Promise<boolean> {
|
||||
if (!response.body) throw new Error("Missing event stream");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const parts = buffer.split("\n\n");
|
||||
buffer = parts.pop() ?? "";
|
||||
|
||||
for (const part of parts) {
|
||||
const line = part.trim();
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === "[DONE]") return;
|
||||
try {
|
||||
let cursor = initialCursor;
|
||||
const frame = (part: string): boolean => {
|
||||
const lines = part.split("\n");
|
||||
const data = lines
|
||||
.filter((line) => line.startsWith("data:"))
|
||||
.map((line) => line.slice(5).trimStart())
|
||||
.join("\n");
|
||||
const rawId = lines
|
||||
.find((line) => line.startsWith("id:"))
|
||||
?.slice(3)
|
||||
.trim();
|
||||
const id = rawId ? Number(rawId) : undefined;
|
||||
if (!data || (id !== undefined && id <= cursor)) return false;
|
||||
if (data === "[DONE]") return true;
|
||||
// Invalid payloads fail the connection so a durable event is never silently skipped.
|
||||
const event = JSON.parse(data) as SSEEvent;
|
||||
onEvent(event);
|
||||
} catch {
|
||||
// ignore malformed events
|
||||
if (id !== undefined && Number.isSafeInteger(id) && id > 0) {
|
||||
cursor = id;
|
||||
onCursor?.(id);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
try {
|
||||
while (true) {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
const { done, value } = await Promise.race([
|
||||
reader.read(),
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(
|
||||
() => reject(new Error("Event stream went silent")),
|
||||
25000,
|
||||
);
|
||||
}),
|
||||
]).finally(() => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
});
|
||||
if (done) return false;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
// Normalize complete CRLF pairs, including a pair split across chunks.
|
||||
buffer = buffer.replace(/\r\n/g, "\n");
|
||||
let boundary: number;
|
||||
while ((boundary = buffer.indexOf("\n\n")) !== -1) {
|
||||
const part = buffer.slice(0, boundary);
|
||||
buffer = buffer.slice(boundary + 2);
|
||||
if (frame(part)) return true;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
export type HistoryConversation = {
|
||||
id: string;
|
||||
name: string;
|
||||
pinned: boolean;
|
||||
snippet?: string;
|
||||
match_message_id?: string | null;
|
||||
};
|
||||
export type SavedMemory = { id: string; content: string; updated_at: string };
|
||||
|
||||
async function request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const response = await userService.fetchWithAuth(
|
||||
`/api/history${path}`,
|
||||
options,
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error("Unable to update or load history. Please try again.");
|
||||
return response.status === 204 ? (undefined as T) : response.json();
|
||||
}
|
||||
|
||||
export const historyService = {
|
||||
list: ({ limit = 20, offset = 0, search = "" } = {}) =>
|
||||
request<HistoryConversation[]>(
|
||||
`/conversations?${new URLSearchParams({ limit: String(limit), offset: String(offset), search })}`,
|
||||
),
|
||||
pin: (id: string, pinned: boolean) =>
|
||||
request(`/conversations/${encodeURIComponent(id)}/pin`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ pinned }),
|
||||
}),
|
||||
async export(id: string, format: "json" | "markdown") {
|
||||
const response = await userService.fetchWithAuth(
|
||||
`/api/history/conversations/${encodeURIComponent(id)}/export?format=${format}`,
|
||||
);
|
||||
if (!response.ok)
|
||||
throw new Error("Unable to export this conversation. Please try again.");
|
||||
const url = URL.createObjectURL(await response.blob());
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = `conversation-${id}.${format === "json" ? "json" : "md"}`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
},
|
||||
memories: (offset = 0) =>
|
||||
request<SavedMemory[]>(`/memories?limit=100&offset=${offset}`),
|
||||
correctMemory: (id: string, content: string) =>
|
||||
request<SavedMemory>(`/memories/${encodeURIComponent(id)}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ content }),
|
||||
}),
|
||||
forgetMemory: (id: string) =>
|
||||
request<void>(`/memories/${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||
};
|
||||
@@ -1,13 +1,18 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
export type ScheduledMessageStatus = "pending" | "processing" | "sent" | "failed" | "uncertain" | "cancelled";
|
||||
export type DeliveryStatus = ScheduledMessageStatus | "sending";
|
||||
|
||||
export interface ScheduledMessage {
|
||||
id: string;
|
||||
series_id: string;
|
||||
retryable: boolean;
|
||||
recipient: string;
|
||||
channel: "imessage" | "email";
|
||||
content: string;
|
||||
subject: string | null;
|
||||
scheduled_at: string;
|
||||
status: "pending" | "sent" | "failed" | "cancelled";
|
||||
status: ScheduledMessageStatus;
|
||||
recurrence: "none" | "daily" | "weekly" | "monthly";
|
||||
use_agent: boolean;
|
||||
error_message: string | null;
|
||||
@@ -15,6 +20,23 @@ export interface ScheduledMessage {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface DeliveryAttempt {
|
||||
id: string;
|
||||
status: DeliveryStatus;
|
||||
error: string | null;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
export interface ScheduledDelivery {
|
||||
id: string;
|
||||
run_id: string;
|
||||
status: DeliveryStatus;
|
||||
error: string | null;
|
||||
retryable: boolean;
|
||||
attempts: DeliveryAttempt[];
|
||||
}
|
||||
|
||||
export interface CreateScheduledMessage {
|
||||
recipient: string;
|
||||
channel: "imessage" | "email";
|
||||
@@ -25,12 +47,27 @@ export interface CreateScheduledMessage {
|
||||
use_agent?: boolean;
|
||||
}
|
||||
|
||||
export function canRetryScheduledMessage(message: Pick<ScheduledMessage, "status" | "retryable">): boolean {
|
||||
return message.status === "failed" && message.retryable === true;
|
||||
}
|
||||
|
||||
async function checkResponse(response: Response, fallback: string): Promise<void> {
|
||||
if (response.ok) return;
|
||||
const payload = await response.json().catch(() => null);
|
||||
const detail = payload?.error || payload?.error_message;
|
||||
throw new Error(typeof detail === "string" ? detail : `${fallback}${payload?.status ? ` (${payload.status})` : ""}`);
|
||||
}
|
||||
|
||||
class ScheduledMessageService {
|
||||
private baseUrl = "/api/scheduled-messages";
|
||||
|
||||
private messageUrl(id: string): string {
|
||||
return `${this.baseUrl}/${encodeURIComponent(id)}`;
|
||||
}
|
||||
|
||||
async list(): Promise<ScheduledMessage[]> {
|
||||
const response = await userService.fetchWithAuth(`${this.baseUrl}/`);
|
||||
if (!response.ok) throw new Error("Failed to list scheduled messages");
|
||||
await checkResponse(response, "Failed to list scheduled messages");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
@@ -39,33 +76,42 @@ class ScheduledMessageService {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to create scheduled message");
|
||||
}
|
||||
await checkResponse(response, "Failed to create scheduled message");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: string }): Promise<ScheduledMessage> {
|
||||
const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
|
||||
async update(id: string, data: Partial<CreateScheduledMessage> & { status?: "cancelled" }): Promise<ScheduledMessage> {
|
||||
// Runtime guard also protects callers using untyped data: retry has its own endpoint.
|
||||
if (data.status !== undefined && data.status !== "cancelled") {
|
||||
throw new Error("Status changes require the explicit retry endpoint; only cancellation is allowed here");
|
||||
}
|
||||
const response = await userService.fetchWithAuth(this.messageUrl(id), {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to update scheduled message");
|
||||
}
|
||||
await checkResponse(response, "Failed to update scheduled message");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const response = await userService.fetchWithAuth(`${this.baseUrl}/${id}`, {
|
||||
const response = await userService.fetchWithAuth(this.messageUrl(id), {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error ?? "Failed to delete scheduled message");
|
||||
await checkResponse(response, "Failed to delete scheduled message");
|
||||
}
|
||||
|
||||
async deliveries(id: string): Promise<ScheduledDelivery[]> {
|
||||
const response = await userService.fetchWithAuth(`${this.messageUrl(id)}/deliveries`);
|
||||
await checkResponse(response, "Failed to load delivery history");
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async retry(id: string): Promise<ScheduledMessage> {
|
||||
const response = await userService.fetchWithAuth(`${this.messageUrl(id)}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
await checkResponse(response, "Failed to retry scheduled message");
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,10 @@ import { SidebarContent } from "./SidebarContent";
|
||||
import { AdminPanel } from "./AdminPanel";
|
||||
import { ScheduledMessagesPanel } from "./ScheduledMessagesPanel";
|
||||
import { ArtifactPanel } from "./artifacts/ArtifactPanel";
|
||||
import { AUTO_OPEN_MIN_WIDTH, useArtifactPanel } from "./artifacts/useArtifactPanel";
|
||||
import {
|
||||
AUTO_OPEN_MIN_WIDTH,
|
||||
useArtifactPanel,
|
||||
} from "./artifacts/useArtifactPanel";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useConversations } from "../hooks/useConversations";
|
||||
import { useChat, type ToolMessage } from "../hooks/useChat";
|
||||
@@ -50,9 +53,14 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
renameConversation,
|
||||
deleteConversation,
|
||||
refreshConversations,
|
||||
pinConversation,
|
||||
exportConversation,
|
||||
} = useConversations();
|
||||
|
||||
const onSessionExpired = useCallback(() => setAuthenticated(false), [setAuthenticated]);
|
||||
const onSessionExpired = useCallback(
|
||||
() => setAuthenticated(false),
|
||||
[setAuthenticated],
|
||||
);
|
||||
|
||||
const conversationId = useMatch("/c/:conversationId")?.params.conversationId;
|
||||
const conversationIdRef = useRef(conversationId);
|
||||
@@ -83,6 +91,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
messages,
|
||||
isLoading,
|
||||
streaming,
|
||||
reconnecting,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingAttachment,
|
||||
@@ -127,16 +136,22 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
setQuery("");
|
||||
}, [query, simbaMode, sendMessage]);
|
||||
|
||||
const handleQueryChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const handleQueryChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setQuery(event.target.value);
|
||||
}, []);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleQuestionSubmit();
|
||||
}
|
||||
}, [handleQuestionSubmit]);
|
||||
},
|
||||
[handleQuestionSubmit],
|
||||
);
|
||||
|
||||
const handleToggleSimbaMode = useCallback(() => setSimbaMode((v) => !v), []);
|
||||
|
||||
@@ -157,6 +172,8 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
onSelectConversation: handleSelectConversation,
|
||||
onRenameConversation: renameConversation,
|
||||
onDeleteConversation: handleDeleteConversation,
|
||||
onPinConversation: pinConversation,
|
||||
onExportConversation: exportConversation,
|
||||
selectedId: selectedConversation?.id,
|
||||
isAdmin,
|
||||
};
|
||||
@@ -221,8 +238,12 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAdminPanel && <AdminPanel onClose={() => setShowAdminPanel(false)} />}
|
||||
{showScheduler && <ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />}
|
||||
{showAdminPanel && (
|
||||
<AdminPanel onClose={() => setShowAdminPanel(false)} />
|
||||
)}
|
||||
{showScheduler && (
|
||||
<ScheduledMessagesPanel onClose={() => setShowScheduler(false)} />
|
||||
)}
|
||||
|
||||
<div className="relative flex-1 flex flex-col h-full overflow-hidden min-w-0">
|
||||
<DropOverlay onDrop={attachFile} />
|
||||
@@ -325,7 +346,10 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
return null;
|
||||
}
|
||||
let end = index;
|
||||
while (end < messages.length && messages[end].speaker === "tool") {
|
||||
while (
|
||||
end < messages.length &&
|
||||
messages[end].speaker === "tool"
|
||||
) {
|
||||
end++;
|
||||
}
|
||||
const rows = messages.slice(index, end) as ToolMessage[];
|
||||
@@ -355,7 +379,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
msg.speaker === "simba" ? (
|
||||
<AnswerBubble key={index} text={msg.text} />
|
||||
) : (
|
||||
<QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />
|
||||
<QuestionBubble
|
||||
key={index}
|
||||
text={msg.text}
|
||||
image_key={msg.image_key}
|
||||
/>
|
||||
);
|
||||
|
||||
if (showThinkingHere) {
|
||||
@@ -374,7 +402,9 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
})}
|
||||
|
||||
{/* ThinkingBubble before content arrives (no streaming message yet) */}
|
||||
{isLoading && !streaming && (thinking || thinkingText || thinkingDurationMs !== null) && (
|
||||
{isLoading &&
|
||||
!streaming &&
|
||||
(thinking || thinkingText || thinkingDurationMs !== null) && (
|
||||
<ThinkingBubble
|
||||
text={thinkingText}
|
||||
durationMs={thinkingDurationMs}
|
||||
@@ -387,10 +417,13 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
!streaming &&
|
||||
!thinking &&
|
||||
messages[messages.length - 1]?.speaker !== "tool") ||
|
||||
messagesLoading) && (
|
||||
<AnswerBubble text="" loading={true} />
|
||||
)}
|
||||
messagesLoading) && <AnswerBubble text="" loading={true} />}
|
||||
|
||||
{reconnecting && (
|
||||
<p role="status" className="text-sm text-muted-foreground">
|
||||
Connection lost. Reconnecting to your running answer…
|
||||
</p>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-red-50 border border-red-200 text-xs text-red-600">
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Check, MoreHorizontal, Pencil, Plus, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
Download,
|
||||
Pin,
|
||||
PinOff,
|
||||
Check,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
};
|
||||
import type { Conversation } from "../hooks/useConversations";
|
||||
|
||||
type ConversationProps = {
|
||||
conversations: Conversation[];
|
||||
@@ -13,6 +20,11 @@ type ConversationProps = {
|
||||
onCreateNewConversation: () => void;
|
||||
onRenameConversation?: (id: string, title: string) => void;
|
||||
onDeleteConversation?: (id: string) => void;
|
||||
onPinConversation?: (id: string, pinned: boolean) => void | Promise<void>;
|
||||
onExportConversation?: (
|
||||
id: string,
|
||||
format: "json" | "markdown",
|
||||
) => void | Promise<void>;
|
||||
selectedId?: string;
|
||||
variant?: "dark" | "light";
|
||||
};
|
||||
@@ -23,6 +35,8 @@ export const ConversationList = ({
|
||||
onCreateNewConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
onPinConversation,
|
||||
onExportConversation,
|
||||
selectedId,
|
||||
variant = "dark",
|
||||
}: ConversationProps) => {
|
||||
@@ -30,6 +44,16 @@ export const ConversationList = ({
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editValue, setEditValue] = useState("");
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
const [actionError, setActionError] = useState("");
|
||||
const runAction = async (action: () => void | Promise<void>) => {
|
||||
setMenuOpenId(null);
|
||||
setActionError("");
|
||||
try {
|
||||
await action();
|
||||
} catch {
|
||||
setActionError("Unable to complete action. Please try again.");
|
||||
}
|
||||
};
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isDark = variant === "dark";
|
||||
@@ -41,8 +65,15 @@ export const ConversationList = ({
|
||||
setMenuOpenId(null);
|
||||
setConfirmDeleteId(null);
|
||||
};
|
||||
const escape = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") close();
|
||||
};
|
||||
window.addEventListener("click", close);
|
||||
return () => window.removeEventListener("click", close);
|
||||
window.addEventListener("keydown", escape);
|
||||
return () => {
|
||||
window.removeEventListener("click", close);
|
||||
window.removeEventListener("keydown", escape);
|
||||
};
|
||||
}, [menuOpenId, confirmDeleteId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -62,10 +93,20 @@ export const ConversationList = ({
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const hasActions = Boolean(onRenameConversation || onDeleteConversation);
|
||||
const hasActions = Boolean(
|
||||
onRenameConversation ||
|
||||
onDeleteConversation ||
|
||||
onPinConversation ||
|
||||
onExportConversation,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{actionError && (
|
||||
<p role="alert" className="text-sm text-red-400">
|
||||
{actionError}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={onCreateNewConversation}
|
||||
className={cn(
|
||||
@@ -152,11 +193,23 @@ export const ConversationList = ({
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
>
|
||||
<span className="block truncate">
|
||||
{conv.pinned && (
|
||||
<Pin size={12} className="inline mr-1" aria-label="Pinned" />
|
||||
)}
|
||||
{conv.title}
|
||||
</span>
|
||||
{conv.snippet && (
|
||||
<span className="block text-xs opacity-75 whitespace-normal line-clamp-2">
|
||||
{conv.snippet}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{hasActions && (
|
||||
<button
|
||||
aria-label={`Actions for ${conv.title}`}
|
||||
aria-expanded={menuOpenId === conv.id}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfirmDeleteId(null);
|
||||
@@ -185,6 +238,42 @@ export const ConversationList = ({
|
||||
: "bg-warm-white border border-sand-light",
|
||||
)}
|
||||
>
|
||||
{onPinConversation && (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm cursor-pointer",
|
||||
isDark
|
||||
? "text-cream/70 hover:text-cream hover:bg-white/10"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
aria-pressed={!!conv.pinned}
|
||||
aria-label={`${conv.pinned ? "Unpin" : "Pin"} ${conv.title}`}
|
||||
onClick={() =>
|
||||
runAction(() => onPinConversation(conv.id, !conv.pinned))
|
||||
}
|
||||
>
|
||||
{conv.pinned ? <PinOff size={13} /> : <Pin size={13} />}
|
||||
{conv.pinned ? "Unpin" : "Pin"}
|
||||
</button>
|
||||
)}
|
||||
{onExportConversation &&
|
||||
(["json", "markdown"] as const).map((format) => (
|
||||
<button
|
||||
key={format}
|
||||
className={cn(
|
||||
"flex items-center gap-2 w-full px-2.5 py-1.5 rounded-lg text-sm cursor-pointer",
|
||||
isDark
|
||||
? "text-cream/70 hover:text-cream hover:bg-white/10"
|
||||
: "text-warm-gray hover:text-charcoal hover:bg-cream-dark",
|
||||
)}
|
||||
onClick={() =>
|
||||
runAction(() => onExportConversation(conv.id, format))
|
||||
}
|
||||
>
|
||||
<Download size={13} /> Export{" "}
|
||||
{format === "json" ? "JSON" : "Markdown"}
|
||||
</button>
|
||||
))}
|
||||
{onRenameConversation && (
|
||||
<button
|
||||
onClick={() => startRename(conv)}
|
||||
@@ -217,7 +306,9 @@ export const ConversationList = ({
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
<span>
|
||||
{confirmDeleteId === conv.id ? "Confirm delete" : "Delete"}
|
||||
{confirmDeleteId === conv.id
|
||||
? "Confirm delete"
|
||||
: "Delete"}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,9 @@ export const ConversationsPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
items,
|
||||
error,
|
||||
pinConversation,
|
||||
exportConversation,
|
||||
search,
|
||||
setSearch,
|
||||
hasMore,
|
||||
@@ -21,7 +24,10 @@ export const ConversationsPage = () => {
|
||||
} = useConversationBrowser();
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(conversation: Conversation) => navigate(`/c/${conversation.id}`),
|
||||
(conversation: Conversation) =>
|
||||
navigate(
|
||||
`/c/${conversation.id}${conversation.matchMessageId ? `?message=${encodeURIComponent(conversation.matchMessageId)}` : ""}`,
|
||||
),
|
||||
[navigate],
|
||||
);
|
||||
|
||||
@@ -56,16 +62,30 @@ export const ConversationsPage = () => {
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search conversations..."
|
||||
placeholder="Search titles and messages..."
|
||||
aria-label="Search conversation titles and messages"
|
||||
maxLength={500}
|
||||
className="h-10 pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{loading && (
|
||||
<p role="status" className="text-sm text-warm-gray">
|
||||
Loading conversations...
|
||||
</p>
|
||||
)}
|
||||
<div className="bg-warm-white rounded-2xl border border-sand-light p-3 shadow-sm">
|
||||
<ConversationList
|
||||
conversations={items}
|
||||
onCreateNewConversation={handleCreateNew}
|
||||
onSelectConversation={handleSelect}
|
||||
onPinConversation={pinConversation}
|
||||
onExportConversation={exportConversation}
|
||||
onRenameConversation={renameConversation}
|
||||
onDeleteConversation={deleteConversation}
|
||||
variant="light"
|
||||
@@ -73,7 +93,9 @@ export const ConversationsPage = () => {
|
||||
|
||||
{items.length === 0 && !loading && (
|
||||
<p className="text-center text-sm text-warm-gray/70 py-6">
|
||||
{search ? "No conversations match your search." : "No conversations yet."}
|
||||
{search
|
||||
? "No conversations match your search."
|
||||
: "No conversations yet."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { X, RefreshCw } from "lucide-react";
|
||||
import { userService } from "../api/userService";
|
||||
|
||||
type Status = { name: string; status: string; message: string; last_success_at: string | null; indexed_chunks?: number };
|
||||
|
||||
export const IntegrationStatusPanel = ({ onClose }: { onClose: () => void }) => {
|
||||
const [items, setItems] = useState<Status[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await userService.fetchWithAuth("/api/integrations/", { signal: controller.signal });
|
||||
if (!response.ok) throw new Error("Unable to load integration status.");
|
||||
const body = await response.json();
|
||||
if (!controller.signal.aborted) setItems(body.integrations);
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) setError(err instanceof Error ? err.message : "Unable to load integration status.");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [revision]);
|
||||
useEffect(() => {
|
||||
const close = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); };
|
||||
window.addEventListener("keydown", close);
|
||||
return () => window.removeEventListener("keydown", close);
|
||||
}, [onClose]);
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-charcoal/30 flex items-center justify-center p-4" onClick={onClose}>
|
||||
<section role="dialog" aria-modal="true" aria-labelledby="integration-status-title" className="bg-cream rounded-2xl shadow-xl w-full max-w-xl max-h-[85vh] overflow-y-auto p-5" onClick={(e) => e.stopPropagation()}>
|
||||
<header className="flex items-center justify-between gap-3 mb-4">
|
||||
<h2 id="integration-status-title" className="font-semibold text-charcoal">Integrations and data freshness</h2>
|
||||
<div className="flex gap-2">
|
||||
<button aria-label="Refresh integration status" disabled={loading} onClick={() => setRevision((r) => r + 1)} className="p-2 rounded-lg hover:bg-cream-dark"><RefreshCw size={18} /></button>
|
||||
<button aria-label="Close integration status" onClick={onClose} className="p-2 rounded-lg hover:bg-cream-dark"><X size={18} /></button>
|
||||
</div>
|
||||
</header>
|
||||
{loading && <p role="status" className="text-sm text-warm-gray">Checking data freshness…</p>}
|
||||
{error && <p role="alert" className="text-sm text-red-700">{error}</p>}
|
||||
<ul className="space-y-3">
|
||||
{items.map((item) => <li key={item.name} className="border border-sand-light rounded-xl p-3">
|
||||
<div className="flex justify-between gap-2"><span className="font-medium">{item.name}</span><span className={item.status === "ready" ? "text-leaf-dark text-sm" : item.status === "stale" || item.status === "unavailable" ? "text-amber-800 text-sm" : "text-warm-gray text-sm"}>{item.status.replace(/_/g, " ")}</span></div>
|
||||
<p className="text-sm text-warm-gray mt-1">{item.message}</p>
|
||||
{item.last_success_at && <p className="text-xs text-warm-gray mt-1">Last synchronized: {new Date(item.last_success_at).toLocaleString()}</p>}
|
||||
</li>)}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { historyService, type SavedMemory } from "../api/historyService";
|
||||
|
||||
type MemoryPanelProps = { onClose?: () => void };
|
||||
|
||||
/** Mount inside a page or dialog. Only the authenticated user's saved memories are loaded. */
|
||||
export function MemoryPanel({ onClose }: MemoryPanelProps) {
|
||||
const [memories, setMemories] = useState<SavedMemory[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [content, setContent] = useState("");
|
||||
const [forgetting, setForgetting] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState("");
|
||||
const offset = useRef(0);
|
||||
const generation = useRef(0);
|
||||
|
||||
const load = useCallback(async (append = false) => {
|
||||
const requestId = ++generation.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const page = await historyService.memories(append ? offset.current : 0);
|
||||
if (requestId !== generation.current) return;
|
||||
offset.current = (append ? offset.current : 0) + page.length;
|
||||
setMemories((previous) =>
|
||||
append
|
||||
? [
|
||||
...previous,
|
||||
...page.filter((m) => !previous.some((old) => old.id === m.id)),
|
||||
]
|
||||
: page,
|
||||
);
|
||||
setHasMore(page.length === 100);
|
||||
} catch {
|
||||
if (requestId === generation.current)
|
||||
setError("Unable to load memories. Please try again.");
|
||||
} finally {
|
||||
if (requestId === generation.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
void load();
|
||||
return () => {
|
||||
++generation.current;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
const save = async (memory: SavedMemory) => {
|
||||
setBusy(memory.id);
|
||||
setError("");
|
||||
try {
|
||||
await historyService.correctMemory(memory.id, content.trim());
|
||||
setEditing(null);
|
||||
setNotice(
|
||||
"Memory corrected. Future responses will use the saved correction.",
|
||||
);
|
||||
await load();
|
||||
} catch {
|
||||
setError(
|
||||
"Unable to correct memory. Your edit is still here; please try again.",
|
||||
);
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
const forget = async (memory: SavedMemory) => {
|
||||
setBusy(memory.id);
|
||||
setError("");
|
||||
try {
|
||||
await historyService.forgetMemory(memory.id);
|
||||
setForgetting(null);
|
||||
if (editing === memory.id) setEditing(null);
|
||||
setNotice(
|
||||
"Memory forgotten. Existing conversation messages are retained.",
|
||||
);
|
||||
await load();
|
||||
} catch {
|
||||
setError("Unable to forget memory. Please try again.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
aria-label="Saved memories"
|
||||
className="bg-warm-white text-charcoal rounded-2xl p-4 flex flex-col gap-3 max-h-[80dvh] overflow-y-auto"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<h2 className="font-bold text-lg">Saved memories</h2>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-2 rounded-xl hover:bg-cream-dark"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-warm-gray">
|
||||
Correct facts Simba remembers about you, or forget a saved memory.
|
||||
</p>
|
||||
{error && (
|
||||
<div role="alert">
|
||||
<p>{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || !!busy}
|
||||
onClick={() => load()}
|
||||
>
|
||||
Retry loading
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{notice && (
|
||||
<p role="status" className="text-sm">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{loading && <p role="status">Loading memories...</p>}
|
||||
{!loading && !error && memories.length === 0 && (
|
||||
<p>No saved memories yet.</p>
|
||||
)}
|
||||
{memories.map((memory) => (
|
||||
<div
|
||||
key={memory.id}
|
||||
className="border border-sand-light rounded-xl p-3 flex flex-col gap-2"
|
||||
>
|
||||
{editing === memory.id ? (
|
||||
<>
|
||||
<label htmlFor={`memory-${memory.id}`} className="text-sm">
|
||||
Correct memory
|
||||
</label>
|
||||
<textarea
|
||||
id={`memory-${memory.id}`}
|
||||
autoFocus
|
||||
maxLength={10000}
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
className="border border-sand-light rounded-lg p-2 min-h-24"
|
||||
/>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy || !content.trim()}
|
||||
onClick={() => save(memory)}
|
||||
>
|
||||
Save correction
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="whitespace-pre-wrap break-words">{memory.content}</p>
|
||||
)}
|
||||
<div className="flex gap-3 text-sm">
|
||||
{editing !== memory.id && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || !!busy}
|
||||
onClick={() => {
|
||||
setContent(memory.content);
|
||||
setEditing(memory.id);
|
||||
setForgetting(null);
|
||||
}}
|
||||
>
|
||||
Correct
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || !!busy}
|
||||
onClick={() => setForgetting(memory.id)}
|
||||
>
|
||||
Forget
|
||||
</button>
|
||||
</div>
|
||||
{forgetting === memory.id && (
|
||||
<div className="text-sm flex flex-col gap-2">
|
||||
<p>Forget this saved memory?</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => forget(memory)}
|
||||
>
|
||||
Confirm forget
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!!busy}
|
||||
onClick={() => setForgetting(null)}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={loading || !!busy}
|
||||
onClick={() => load(true)}
|
||||
>
|
||||
Load more memories
|
||||
</button>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { X, Clock, Send, Trash2, XCircle, RotateCcw, Repeat, Bot } from "lucide-react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
import { useScheduledMessages } from "../hooks/useScheduledMessages";
|
||||
import {
|
||||
scheduledMessageService,
|
||||
canRetryScheduledMessage,
|
||||
type DeliveryStatus,
|
||||
type ScheduledDelivery,
|
||||
type ScheduledMessage,
|
||||
type CreateScheduledMessage,
|
||||
} from "../api/scheduledMessageService";
|
||||
|
||||
@@ -24,13 +28,56 @@ type Props = {
|
||||
|
||||
const STATUS_BADGE: Record<string, "amber" | "default" | "destructive" | "muted"> = {
|
||||
pending: "amber",
|
||||
processing: "amber",
|
||||
sending: "amber",
|
||||
uncertain: "destructive",
|
||||
sent: "default",
|
||||
failed: "destructive",
|
||||
cancelled: "muted",
|
||||
};
|
||||
|
||||
export const DELIVERY_STATUS_LABELS: Record<DeliveryStatus, string> = {
|
||||
pending: "Pending",
|
||||
processing: "Processing",
|
||||
sending: "Sending",
|
||||
sent: "Sent",
|
||||
failed: "Failed",
|
||||
uncertain: "Uncertain — needs reconciliation",
|
||||
cancelled: "Cancelled",
|
||||
};
|
||||
|
||||
export const UNCERTAIN_DELIVERY_HELP = "Delivery may already have occurred. Reconcile with the provider or recipient before any resend; uncertain deliveries cannot be retried.";
|
||||
|
||||
export function DeliveryHistory({ deliveries }: { deliveries: ScheduledDelivery[] }) {
|
||||
if (deliveries.length === 0) return <p className="text-xs text-warm-gray">No delivery attempts yet.</p>;
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{deliveries.map((delivery) => (
|
||||
<div key={delivery.id} className="rounded-xl border border-sand-light p-3 text-xs space-y-2">
|
||||
<p className="break-all">Run: {delivery.run_id}</p>
|
||||
<Badge variant={STATUS_BADGE[delivery.status]}>{DELIVERY_STATUS_LABELS[delivery.status]}</Badge>
|
||||
{delivery.error && <p className="text-red-500 break-words">{delivery.error}</p>}
|
||||
{delivery.status === "uncertain" && <p>{UNCERTAIN_DELIVERY_HELP}</p>}
|
||||
{delivery.attempts.length === 0 ? <p>No attempts recorded.</p> : (
|
||||
<ol className="space-y-2">
|
||||
{delivery.attempts.map((attempt, index) => (
|
||||
<li key={attempt.id}>
|
||||
<p>Attempt {index + 1}: {DELIVERY_STATUS_LABELS[attempt.status]}</p>
|
||||
<p>Started: {new Date(attempt.started_at).toLocaleString()}</p>
|
||||
<p>{attempt.finished_at ? `Finished: ${new Date(attempt.finished_at).toLocaleString()}` : "Not finished"}</p>
|
||||
{attempt.error && <p className="text-red-500 break-words">{attempt.error}</p>}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
const { messages, loading, refresh } = useScheduledMessages();
|
||||
const { messages, loading, error: listError, refresh } = useScheduledMessages();
|
||||
const [channel, setChannel] = useState<"imessage" | "email">("imessage");
|
||||
const [recipient, setRecipient] = useState("");
|
||||
const [subject, setSubject] = useState("");
|
||||
@@ -40,6 +87,28 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
const [useAgent, setUseAgent] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const actionInFlight = useRef(false);
|
||||
const [historyId, setHistoryId] = useState<string | null>(null);
|
||||
const [deliveries, setDeliveries] = useState<ScheduledDelivery[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState("");
|
||||
const [historyVersion, setHistoryVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!historyId) return;
|
||||
let active = true;
|
||||
setHistoryLoading(true);
|
||||
setHistoryError("");
|
||||
setDeliveries([]);
|
||||
scheduledMessageService.deliveries(historyId)
|
||||
.then((result) => { if (active) setDeliveries(result); })
|
||||
.catch((err) => {
|
||||
if (active) setHistoryError(err instanceof Error ? err.message : "Failed to load delivery history");
|
||||
})
|
||||
.finally(() => { if (active) setHistoryLoading(false); });
|
||||
return () => { active = false; };
|
||||
}, [historyId, historyVersion]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
setError("");
|
||||
@@ -78,26 +147,31 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = async (id: string) => {
|
||||
const runAction = async (message: ScheduledMessage, action: "cancel" | "delete" | "retry") => {
|
||||
if (actionInFlight.current) return;
|
||||
if (action === "retry" && !canRetryScheduledMessage(message)) {
|
||||
setError("Only failed, retryable deliveries can be retried.");
|
||||
return;
|
||||
}
|
||||
actionInFlight.current = true;
|
||||
setBusyId(message.id);
|
||||
setError("");
|
||||
try {
|
||||
await scheduledMessageService.update(id, { status: "cancelled" });
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await scheduledMessageService.remove(id);
|
||||
refresh();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
const handleRetry = async (id: string) => {
|
||||
try {
|
||||
const futureTime = new Date(Date.now() + 30_000).toISOString();
|
||||
await scheduledMessageService.update(id, { scheduled_at: futureTime });
|
||||
refresh();
|
||||
} catch {}
|
||||
if (action === "cancel") await scheduledMessageService.update(message.id, { status: "cancelled" });
|
||||
if (action === "delete") {
|
||||
await scheduledMessageService.remove(message.id);
|
||||
if (historyId === message.id) setHistoryId(null);
|
||||
}
|
||||
if (action === "retry") await scheduledMessageService.retry(message.id);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : `Failed to ${action} scheduled message`);
|
||||
} finally {
|
||||
// A rejected retry can still have changed the delivery to failed or uncertain.
|
||||
await refresh();
|
||||
setHistoryVersion((version) => version + 1);
|
||||
actionInFlight.current = false;
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -226,7 +300,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
className="w-full rounded-xl border border-sand bg-cream-light px-3 py-2 text-sm text-charcoal placeholder:text-warm-gray/50 focus:outline-none focus:ring-2 focus:ring-leaf/30 resize-none"
|
||||
/>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{error && <p role="alert" className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<Button onClick={handleCreate} disabled={submitting} size="sm">
|
||||
<Send size={12} />
|
||||
@@ -235,6 +309,11 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
</div>
|
||||
|
||||
{/* Message list */}
|
||||
{listError && (
|
||||
<div role="alert" className="px-6 py-3 text-xs text-red-500">
|
||||
{listError} <Button size="sm" variant="ghost-dark" onClick={() => void refresh()}>Refresh messages</Button>
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
<div className="flex justify-center gap-1.5 mb-3">
|
||||
@@ -246,7 +325,7 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
</div>
|
||||
) : messages.length === 0 ? (
|
||||
<div className="px-6 py-12 text-center text-warm-gray text-sm">
|
||||
No scheduled messages yet.
|
||||
{listError ? "Scheduled messages could not be loaded." : "No scheduled messages yet."}
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
@@ -287,26 +366,35 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
{msg.recurrence === "none" ? "—" : msg.recurrence}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={STATUS_BADGE[msg.status]}>{msg.status}</Badge>
|
||||
<Badge variant={STATUS_BADGE[msg.status]}>{DELIVERY_STATUS_LABELS[msg.status]}</Badge>
|
||||
{msg.error_message && <p className="text-xs text-red-500 mt-1 break-words">{msg.error_message}</p>}
|
||||
{msg.status === "uncertain" && <p className="text-xs text-warm-gray mt-1">{UNCERTAIN_DELIVERY_HELP}</p>}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{msg.status === "pending" && (
|
||||
<Button size="sm" variant="ghost-dark" onClick={() => setHistoryId(msg.id)} aria-label={`Delivery history for ${msg.recipient}`} title="Delivery history">
|
||||
<Clock size={11} />
|
||||
</Button>
|
||||
{(msg.status === "pending" || msg.status === "failed" || (msg.recurrence !== "none" && msg.status !== "cancelled")) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => handleCancel(msg.id)}
|
||||
title="Cancel"
|
||||
onClick={() => void runAction(msg, "cancel")}
|
||||
disabled={busyId !== null}
|
||||
aria-label={msg.recurrence === "none" ? "Cancel message" : "Cancel series"}
|
||||
title={msg.recurrence === "none" ? "Cancel message" : "Cancel series — stop future occurrences; an in-flight delivery may still complete"}
|
||||
>
|
||||
<XCircle size={11} />
|
||||
</Button>
|
||||
)}
|
||||
{msg.status === "failed" && (
|
||||
{canRetryScheduledMessage(msg) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost-dark"
|
||||
onClick={() => handleRetry(msg.id)}
|
||||
title="Retry"
|
||||
onClick={() => void runAction(msg, "retry")}
|
||||
disabled={busyId !== null}
|
||||
aria-label="Retry failed delivery"
|
||||
title="Retry failed delivery"
|
||||
>
|
||||
<RotateCcw size={11} />
|
||||
</Button>
|
||||
@@ -315,8 +403,10 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => handleDelete(msg.id)}
|
||||
title="Delete"
|
||||
onClick={() => void runAction(msg, "delete")}
|
||||
disabled={busyId !== null}
|
||||
aria-label={msg.recurrence === "none" ? "Delete message" : "Delete occurrence and cancel series"}
|
||||
title={msg.recurrence === "none" ? "Delete message" : "Delete occurrence and cancel series"}
|
||||
>
|
||||
<Trash2 size={11} />
|
||||
</Button>
|
||||
@@ -328,6 +418,17 @@ export const ScheduledMessagesPanel = ({ onClose }: Props) => {
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
{historyId && (
|
||||
<section aria-label="Delivery attempt history" className="px-6 py-4 border-t border-sand-light space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h3 className="text-sm font-semibold">Delivery attempt history</h3>
|
||||
<Button size="sm" variant="ghost-dark" onClick={() => setHistoryVersion((version) => version + 1)} disabled={historyLoading}>Refresh history</Button>
|
||||
<Button size="sm" variant="ghost-dark" onClick={() => setHistoryId(null)}>Close history</Button>
|
||||
</div>
|
||||
<p className="text-xs break-all">Message: {historyId}</p>
|
||||
{historyLoading ? <p role="status" className="text-xs">Loading delivery history...</p> : historyError ? <p role="alert" className="text-xs text-red-500">{historyError}</p> : <DeliveryHistory deliveries={deliveries} />}
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { MemoryPanel } from "./MemoryPanel";
|
||||
import { IntegrationStatusPanel } from "./IntegrationStatusPanel";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
Brain,
|
||||
Clock,
|
||||
LogOut,
|
||||
PanelLeftClose,
|
||||
@@ -17,6 +21,12 @@ type SidebarContentProps = {
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onRenameConversation: (id: string, title: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
onPinConversation?: (id: string, pinned: boolean) => void | Promise<void>;
|
||||
onExportConversation?: (
|
||||
id: string,
|
||||
format: "json" | "markdown",
|
||||
) => void | Promise<void>;
|
||||
onShowMemories?: () => void;
|
||||
selectedId?: string;
|
||||
isAdmin: boolean;
|
||||
onShowAdmin: () => void;
|
||||
@@ -39,6 +49,9 @@ export const SidebarContent = ({
|
||||
onSelectConversation,
|
||||
onRenameConversation,
|
||||
onDeleteConversation,
|
||||
onPinConversation,
|
||||
onExportConversation,
|
||||
onShowMemories,
|
||||
selectedId,
|
||||
isAdmin,
|
||||
onShowAdmin,
|
||||
@@ -47,8 +60,26 @@ export const SidebarContent = ({
|
||||
onCollapse,
|
||||
onCloseDrawer,
|
||||
}: SidebarContentProps) => {
|
||||
const [memoriesOpen, setMemoriesOpen] = useState(false);
|
||||
const [integrationsOpen, setIntegrationsOpen] = useState(false);
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
useEffect(() => {
|
||||
if (memoriesOpen) dialogRef.current?.showModal();
|
||||
else dialogRef.current?.close();
|
||||
}, [memoriesOpen]);
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{integrationsOpen && <IntegrationStatusPanel onClose={() => setIntegrationsOpen(false)} />}
|
||||
<button className="mx-3 my-2 rounded-lg px-3 py-2 text-left text-sm text-cream hover:bg-white/10" onClick={() => setIntegrationsOpen(true)}>Integrations and data freshness</button>
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
onCancel={() => setMemoriesOpen(false)}
|
||||
onClose={() => setMemoriesOpen(false)}
|
||||
aria-label="Saved memories"
|
||||
className="m-auto w-full max-w-xl rounded-2xl p-0 backdrop:bg-black/40"
|
||||
>
|
||||
{memoriesOpen && <MemoryPanel onClose={() => setMemoriesOpen(false)} />}
|
||||
</dialog>
|
||||
<div className="flex items-center justify-between px-4 py-4 border-b border-white/8">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img src={catIcon} alt="Simba" className="w-12 h-12" />
|
||||
@@ -84,6 +115,8 @@ export const SidebarContent = ({
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRenameConversation={onRenameConversation}
|
||||
onDeleteConversation={onDeleteConversation}
|
||||
onPinConversation={onPinConversation}
|
||||
onExportConversation={onExportConversation}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
</div>
|
||||
@@ -97,6 +130,16 @@ export const SidebarContent = ({
|
||||
<ListTree size={14} />
|
||||
<span>See all conversations</span>
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onShowMemories ? onShowMemories() : setMemoriesOpen(true)
|
||||
}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 rounded-xl text-sm text-cream/50 hover:text-cream hover:bg-white/8 transition-all cursor-pointer"
|
||||
>
|
||||
<Brain size={14} />
|
||||
<span>Saved memories</span>
|
||||
</button>
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { userService } from "../api/userService";
|
||||
|
||||
type Source = { title: string; source: string; content: string; date?: string; headings: string[]; original_url?: string | null };
|
||||
export const SourceViewer = () => {
|
||||
const { sourceId } = useParams();
|
||||
const [source, setSource] = useState<Source | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
setSource(null); setError("");
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await userService.fetchWithAuth(`/api/sources/${encodeURIComponent(sourceId ?? "")}`, { signal: controller.signal });
|
||||
if (!response.ok) throw new Error(response.status === 404 ? "This source is no longer available." : "Unable to load this source.");
|
||||
const result = await response.json();
|
||||
if (!controller.signal.aborted) setSource(result);
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) setError(err instanceof Error ? err.message : "Unable to load this source.");
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [sourceId, revision]);
|
||||
return <main className="min-h-screen bg-cream px-5 py-8 text-charcoal"><article className="mx-auto max-w-3xl">
|
||||
<Link to="/" className="text-leaf-dark underline">Back to chat</Link>
|
||||
{error ? <div role="alert" className="mt-6"><p>{error}</p><button className="mt-2 underline" onClick={() => setRevision((r) => r + 1)}>Retry</button></div> : !source ? <p role="status" className="mt-6">Loading source…</p> : <>
|
||||
<h1 className="mt-6 text-2xl font-bold">{source.title}</h1>
|
||||
<p className="mt-2 text-sm text-warm-gray">{source.source}{source.date ? ` · ${source.date}` : ""}{source.headings.length ? ` · ${source.headings.join(" / ")}` : ""}</p>
|
||||
<p className="my-3 text-sm text-warm-gray">This is the indexed section used for retrieval. The original may have changed since indexing.</p>
|
||||
{source.original_url && <a className="inline-block mb-5 text-leaf-dark underline" href={source.original_url} target="_blank" rel="noopener noreferrer">Open original</a>}
|
||||
<div className="markdown-content rounded-2xl border border-sand-light bg-warm-white p-5"><ReactMarkdown remarkPlugins={[remarkGfm]}>{source.content}</ReactMarkdown></div>
|
||||
</>}
|
||||
</article></main>;
|
||||
};
|
||||
@@ -255,6 +255,12 @@ const PanelBody = ({
|
||||
>
|
||||
{activeEntry.artifact.title}
|
||||
</h2>
|
||||
{activeEntry.artifact.source_ref?.stale && (
|
||||
<p role="status" className="mb-3 rounded-xl bg-amber-pale px-3 py-2 text-sm text-amber-dark">
|
||||
Cached budget data may be stale.
|
||||
{activeEntry.artifact.source_ref.as_of && ` Last synchronized: ${new Date(activeEntry.artifact.source_ref.as_of).toLocaleString()}.`}
|
||||
</p>
|
||||
)}
|
||||
<ArtifactRenderer artifact={activeEntry.artifact} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
@@ -19,13 +19,14 @@ export const SearchDocuments = ({ data }: RendererProps<SearchDocumentsData>) =>
|
||||
return (
|
||||
<li key={i} className="rounded-xl border border-sand-light bg-warm-white px-3 py-2.5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h4 className="text-sm font-bold leading-snug text-charcoal">{r.title}</h4>
|
||||
<h4 className="text-sm font-bold leading-snug text-charcoal">{r.url?.startsWith("/sources/") ? <a className="text-leaf-dark underline" href={r.url} target="_blank" rel="noopener noreferrer">{r.title}</a> : r.title}</h4>
|
||||
{r.document_type && (
|
||||
<span className="shrink-0 rounded-full bg-amber-pale px-2 py-0.5 text-[10px] font-semibold text-amber-dark">
|
||||
{r.document_type}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{r.headings?.length ? <Subtle>{r.headings.join(" / ")}</Subtle> : null}
|
||||
<p className="mt-1 text-[13px] leading-relaxed text-charcoal/80 line-clamp-4">{r.snippet}</p>
|
||||
{(location || r.date) && (
|
||||
<div className="mt-1.5 flex items-center gap-2 text-[11px] text-warm-gray">
|
||||
|
||||
@@ -49,6 +49,9 @@ export type SearchDocumentsData = {
|
||||
filename?: string;
|
||||
path?: string;
|
||||
document_type?: string;
|
||||
url?: string;
|
||||
source_id?: string;
|
||||
headings?: string[];
|
||||
date?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
+329
-174
@@ -4,11 +4,32 @@ import {
|
||||
conversationService,
|
||||
UploadError,
|
||||
type ToolInput,
|
||||
type SSEEvent,
|
||||
type ChatRun,
|
||||
} from "../api/conversationService";
|
||||
import type { ArtifactRef } from "../api/artifactTypes";
|
||||
import { validateAttachment, withAttachedFile } from "../lib/attachments";
|
||||
import type { Conversation } from "./useConversations";
|
||||
|
||||
const RUN_NOTICES_KEY = "simba.runNoticesShown";
|
||||
|
||||
/** True the first time a failed run's notice is shown on this device. */
|
||||
function claimRunNotice(runId: string): boolean {
|
||||
try {
|
||||
const shown: string[] = JSON.parse(
|
||||
localStorage.getItem(RUN_NOTICES_KEY) ?? "[]",
|
||||
);
|
||||
if (shown.includes(runId)) return false;
|
||||
localStorage.setItem(
|
||||
RUN_NOTICES_KEY,
|
||||
JSON.stringify([...shown, runId].slice(-50)),
|
||||
);
|
||||
} catch {
|
||||
// Storage unavailable: showing the notice again is the safe fallback.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export type ToolStatus = "running" | "ok" | "error";
|
||||
|
||||
export type ToolMessage = {
|
||||
@@ -70,10 +91,15 @@ export function useChat({
|
||||
const [attachmentError, setAttachmentError] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [stalled, setStalled] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const activeRunRef = useRef<ChatRun | null>(null);
|
||||
const stopRequestedRef = useRef(false);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
const [thinkingText, setThinkingText] = useState("");
|
||||
const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(null);
|
||||
const [thinkingDurationMs, setThinkingDurationMs] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const stalledQueryRef = useRef<string | null>(null);
|
||||
const onArtifactRef = useRef(onArtifact);
|
||||
@@ -93,9 +119,7 @@ export function useChat({
|
||||
isMountedRef.current = true;
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
// Intentionally do NOT abort on unmount — navigation away lets the
|
||||
// stream finish in the background so the response gets persisted.
|
||||
// stopGeneration() still aborts on explicit user cancel.
|
||||
abortControllerRef.current?.abort(); // detach; server owns generation
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -107,146 +131,17 @@ export function useChat({
|
||||
[scrollToBottom],
|
||||
);
|
||||
|
||||
// Load messages whenever the active conversation (URL) changes. Clearing
|
||||
// immediately makes the switch feel instant instead of lingering on the old
|
||||
// thread until the fetch resolves.
|
||||
useEffect(() => {
|
||||
// Skip only for a conversation we just created locally (its optimistic
|
||||
// message + stream are already in state). Guard against the null sentinel
|
||||
// colliding with the "no conversation" (home) route.
|
||||
if (
|
||||
skipLoadForIdRef.current !== null &&
|
||||
skipLoadForIdRef.current === conversationId
|
||||
) {
|
||||
skipLoadForIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
setMessagesLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setMessages([]);
|
||||
setMessagesLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getConversation(conversationId);
|
||||
if (cancelled) return;
|
||||
setMessages(
|
||||
(fetched.messages ?? []).map((m): Message => {
|
||||
if (m.speaker === "tool") {
|
||||
// Saved tool messages carry no input, status or duration.
|
||||
// Pre-v2 servers send only the "[<tool> result]" header line.
|
||||
const tool = m.tool ?? m.text.match(/^\[(\S+) result\]/)?.[1] ?? "";
|
||||
return {
|
||||
speaker: "tool",
|
||||
run_id: m.id,
|
||||
tool,
|
||||
label: m.label ?? (tool ? fallbackLabel(tool) : "Used a tool"),
|
||||
status: "ok",
|
||||
artifact: m.artifact ?? null,
|
||||
};
|
||||
}
|
||||
return { text: m.text, speaker: m.speaker, image_key: m.image_key };
|
||||
}),
|
||||
);
|
||||
scrollToBottom();
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load messages:", err);
|
||||
setMessages([]);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [conversationId, scrollToBottom]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (query: string, simbaMode: boolean) => {
|
||||
if ((!query.trim() && !pendingAttachment) || isLoading) return;
|
||||
|
||||
let activeId = conversationId;
|
||||
if (!activeId) {
|
||||
const created = await createConversation();
|
||||
// Prevent the route-change loader from clearing what we're about to add.
|
||||
skipLoadForIdRef.current = created.id;
|
||||
activeId = created.id;
|
||||
}
|
||||
|
||||
const attachment = pendingAttachment;
|
||||
|
||||
setError(null);
|
||||
setStalled(false);
|
||||
stalledQueryRef.current = null;
|
||||
setThinkingText("");
|
||||
setThinkingDurationMs(null);
|
||||
thinkingStartRef.current = null;
|
||||
streamingActiveRef.current = false;
|
||||
setStreaming(false);
|
||||
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
||||
setPendingAttachment(null);
|
||||
setAttachmentError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (simbaMode) {
|
||||
const randomElement =
|
||||
simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: randomElement, speaker: "simba" }]),
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
let fileKey: string | undefined;
|
||||
if (attachment) {
|
||||
const uploadResult = await conversationService.uploadAttachment(
|
||||
attachment,
|
||||
activeId,
|
||||
);
|
||||
let patch: Partial<TextMessage>;
|
||||
if (uploadResult.kind === "image") {
|
||||
imageKey = uploadResult.image_key;
|
||||
patch = { image_key: imageKey };
|
||||
} else {
|
||||
fileKey = uploadResult.file_key;
|
||||
patch = { text: withAttachedFile(query, uploadResult.name) };
|
||||
}
|
||||
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
const msg = updated[i];
|
||||
if (msg.speaker === "user") {
|
||||
updated[i] = { ...msg, ...patch };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
await conversationService.streamQuery(
|
||||
query,
|
||||
activeId,
|
||||
(event) => {
|
||||
const handleEvent = useCallback(
|
||||
(event: SSEEvent, activeId: string, query = "") => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (event.type === "thinking") {
|
||||
setThinking(true);
|
||||
if (thinkingStartRef.current === null) thinkingStartRef.current = Date.now();
|
||||
if (thinkingStartRef.current === null)
|
||||
thinkingStartRef.current = Date.now();
|
||||
} else if (event.type === "thinking_delta") {
|
||||
setThinking(true);
|
||||
if (thinkingStartRef.current === null) thinkingStartRef.current = Date.now();
|
||||
if (thinkingStartRef.current === null)
|
||||
thinkingStartRef.current = Date.now();
|
||||
setThinkingText((prev) => prev + event.delta);
|
||||
} else if (event.type === "tool_start") {
|
||||
// Any text after a tool call belongs in a fresh bubble.
|
||||
@@ -270,7 +165,8 @@ export function useChat({
|
||||
updateMessages((prev) => {
|
||||
// Pair by run_id; a legacy end (no run_id) pairs with the
|
||||
// oldest still-running legacy row for the same tool.
|
||||
const idx = prev.findIndex((m) =>
|
||||
const idx = prev.findIndex(
|
||||
(m) =>
|
||||
m.speaker === "tool" &&
|
||||
(event.run_id
|
||||
? m.run_id === event.run_id
|
||||
@@ -283,14 +179,16 @@ export function useChat({
|
||||
} as const;
|
||||
if (idx === -1) {
|
||||
// End without a matching start: show it as a finished row.
|
||||
return prev.concat([{
|
||||
return prev.concat([
|
||||
{
|
||||
speaker: "tool",
|
||||
run_id: event.run_id ?? `legacy-${++legacyRunCounter}`,
|
||||
tool: event.tool,
|
||||
label: event.label ?? fallbackLabel(event.tool),
|
||||
ms: event.ms,
|
||||
...ended,
|
||||
}]);
|
||||
},
|
||||
]);
|
||||
}
|
||||
const row = prev[idx] as ToolMessage;
|
||||
const updated = [...prev];
|
||||
@@ -298,7 +196,9 @@ export function useChat({
|
||||
...row,
|
||||
...ended,
|
||||
label: event.label ?? row.label,
|
||||
ms: event.ms ?? (row.startedAt != null ? now - row.startedAt : undefined),
|
||||
ms:
|
||||
event.ms ??
|
||||
(row.startedAt != null ? now - row.startedAt : undefined),
|
||||
};
|
||||
return updated;
|
||||
});
|
||||
@@ -349,54 +249,299 @@ export function useChat({
|
||||
stalledQueryRef.current = query;
|
||||
setStalled(true);
|
||||
} else {
|
||||
setError("Simba ran into a problem answering that.");
|
||||
if (!stopRequestedRef.current) setError(event.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
abortController.signal,
|
||||
imageKey,
|
||||
fileKey,
|
||||
[updateMessages, thinkingDurationMs],
|
||||
);
|
||||
const handleEventRef = useRef(handleEvent);
|
||||
handleEventRef.current = handleEvent;
|
||||
|
||||
const followRun = useCallback(
|
||||
async (run: ChatRun, controller: AbortController, query = "") => {
|
||||
activeRunRef.current = run;
|
||||
abortControllerRef.current = controller;
|
||||
setIsLoading(true);
|
||||
setReconnecting(false);
|
||||
try {
|
||||
await conversationService.resumeRun(
|
||||
run,
|
||||
(event) => {
|
||||
if (abortControllerRef.current === controller)
|
||||
handleEventRef.current(event, run.conversation_id, query);
|
||||
},
|
||||
controller.signal,
|
||||
(value) => {
|
||||
if (abortControllerRef.current === controller)
|
||||
setReconnecting(value);
|
||||
},
|
||||
);
|
||||
} catch (err) {
|
||||
if (
|
||||
!controller.signal.aborted &&
|
||||
isMountedRef.current &&
|
||||
abortControllerRef.current === controller
|
||||
) {
|
||||
if (err instanceof Error && err.message.includes("Session expired"))
|
||||
onSessionExpired();
|
||||
else
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Connection lost. Reload to resume.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (abortControllerRef.current === controller) {
|
||||
activeRunRef.current = null;
|
||||
abortControllerRef.current = null;
|
||||
setIsLoading(false);
|
||||
setStreaming(false);
|
||||
setThinking(false);
|
||||
setReconnecting(false);
|
||||
streamingActiveRef.current = false;
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.speaker === "tool" && m.status === "running"
|
||||
? { ...m, status: "error", endedAt: Date.now() }
|
||||
: m,
|
||||
),
|
||||
);
|
||||
void refreshConversations();
|
||||
}
|
||||
}
|
||||
},
|
||||
[onSessionExpired, refreshConversations],
|
||||
);
|
||||
|
||||
// Load messages whenever the active conversation (URL) changes. Clearing
|
||||
// immediately makes the switch feel instant instead of lingering on the old
|
||||
// thread until the fetch resolves.
|
||||
useEffect(() => {
|
||||
// Skip only for a conversation we just created locally (its optimistic
|
||||
// message + stream are already in state). Guard against the null sentinel
|
||||
// colliding with the "no conversation" (home) route.
|
||||
if (
|
||||
skipLoadForIdRef.current !== null &&
|
||||
skipLoadForIdRef.current === conversationId
|
||||
) {
|
||||
skipLoadForIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
activeRunRef.current = null;
|
||||
setIsLoading(false);
|
||||
setThinking(false);
|
||||
setThinkingText("");
|
||||
setError(null);
|
||||
setStalled(false);
|
||||
streamingActiveRef.current = false;
|
||||
if (!conversationId) {
|
||||
setMessages([]);
|
||||
setMessagesLoading(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
setMessages([]);
|
||||
setMessagesLoading(true);
|
||||
(async () => {
|
||||
try {
|
||||
const run = await conversationService.discoverRun(conversationId);
|
||||
const fetched =
|
||||
await conversationService.getConversation(conversationId);
|
||||
if (cancelled) return;
|
||||
setMessages(
|
||||
(fetched.messages ?? [])
|
||||
.filter(
|
||||
(m) =>
|
||||
!(
|
||||
run?.status === "running" &&
|
||||
m.speaker !== "user" &&
|
||||
Date.parse(m.created_at) >= Date.parse(run.created_at)
|
||||
),
|
||||
)
|
||||
.map((m): Message => {
|
||||
if (m.speaker === "tool") {
|
||||
// Saved tool messages carry no input, status or duration.
|
||||
// Pre-v2 servers send only the "[<tool> result]" header line.
|
||||
const tool =
|
||||
m.tool ?? m.text.match(/^\[(\S+) result\]/)?.[1] ?? "";
|
||||
return {
|
||||
speaker: "tool",
|
||||
run_id: m.id,
|
||||
tool,
|
||||
label:
|
||||
m.label ?? (tool ? fallbackLabel(tool) : "Used a tool"),
|
||||
status: "ok",
|
||||
artifact: m.artifact ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
text: m.text,
|
||||
speaker: m.speaker,
|
||||
image_key: m.image_key,
|
||||
};
|
||||
}),
|
||||
);
|
||||
scrollToBottom();
|
||||
if (run?.status === "running") {
|
||||
if (
|
||||
!(fetched.messages ?? []).some(
|
||||
(m) =>
|
||||
m.speaker === "user" &&
|
||||
Date.parse(m.created_at) >= Date.parse(run.created_at),
|
||||
)
|
||||
) {
|
||||
setMessages((prev) =>
|
||||
prev.concat([
|
||||
{ speaker: "user", text: run.query, image_key: run.image_key },
|
||||
]),
|
||||
);
|
||||
}
|
||||
void followRun(run, controller, run.query);
|
||||
} else if (
|
||||
(run?.status === "interrupted" || run?.status === "failed") &&
|
||||
claimRunNotice(run.id)
|
||||
) {
|
||||
setError(
|
||||
run.status === "interrupted"
|
||||
? "Generation was interrupted by a server restart. Send your message again."
|
||||
: "The last generation failed. Send your message again.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error("Failed to load messages:", err);
|
||||
setError("Could not load this conversation. Reload to reconnect.");
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setMessagesLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [conversationId, scrollToBottom, followRun]);
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (query: string, simbaMode: boolean) => {
|
||||
if ((!query.trim() && !pendingAttachment) || isLoading) return;
|
||||
|
||||
let activeId = conversationId;
|
||||
if (!activeId) {
|
||||
const created = await createConversation();
|
||||
// Prevent the route-change loader from clearing what we're about to add.
|
||||
skipLoadForIdRef.current = created.id;
|
||||
activeId = created.id;
|
||||
}
|
||||
|
||||
const attachment = pendingAttachment;
|
||||
|
||||
setError(null);
|
||||
setStalled(false);
|
||||
stalledQueryRef.current = null;
|
||||
setThinkingText("");
|
||||
setThinkingDurationMs(null);
|
||||
thinkingStartRef.current = null;
|
||||
streamingActiveRef.current = false;
|
||||
setStreaming(false);
|
||||
updateMessages((prev) => prev.concat([{ text: query, speaker: "user" }]));
|
||||
setPendingAttachment(null);
|
||||
setAttachmentError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
if (simbaMode) {
|
||||
const randomElement =
|
||||
simbaAnswers[Math.floor(Math.random() * simbaAnswers.length)];
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: randomElement, speaker: "simba" }]),
|
||||
);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
stopRequestedRef.current = false;
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
let imageKey: string | undefined;
|
||||
let fileKey: string | undefined;
|
||||
if (attachment) {
|
||||
const uploadResult = await conversationService.uploadAttachment(
|
||||
attachment,
|
||||
activeId,
|
||||
);
|
||||
let patch: Partial<TextMessage>;
|
||||
if (uploadResult.kind === "image") {
|
||||
imageKey = uploadResult.image_key;
|
||||
patch = { image_key: imageKey };
|
||||
} else {
|
||||
fileKey = uploadResult.file_key;
|
||||
patch = { text: withAttachedFile(query, uploadResult.name) };
|
||||
}
|
||||
|
||||
if (abortControllerRef.current === abortController)
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
for (let i = updated.length - 1; i >= 0; i--) {
|
||||
const msg = updated[i];
|
||||
if (msg.speaker === "user") {
|
||||
updated[i] = { ...msg, ...patch };
|
||||
break;
|
||||
}
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
const run = await conversationService.createRun({
|
||||
query,
|
||||
conversation_id: activeId,
|
||||
request_id: crypto.randomUUID(),
|
||||
...(imageKey ? { image_key: imageKey } : {}),
|
||||
...(fileKey ? { file_key: fileKey } : {}),
|
||||
});
|
||||
if (stopRequestedRef.current) await conversationService.cancelRun(run);
|
||||
if (abortController.signal.aborted) return;
|
||||
await followRun(run, abortController, query);
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// User cancelled — not an error to surface.
|
||||
} else if (err instanceof UploadError) {
|
||||
if (isMountedRef.current) setError(`Couldn't attach that file: ${err.message}`);
|
||||
if (
|
||||
isMountedRef.current &&
|
||||
abortControllerRef.current === abortController
|
||||
)
|
||||
setError(`Couldn't attach that file: ${err.message}`);
|
||||
} else {
|
||||
console.error("Failed to send query:", err);
|
||||
if (
|
||||
err instanceof Error &&
|
||||
err.message.includes("Session expired")
|
||||
) {
|
||||
if (err instanceof Error && err.message.includes("Session expired")) {
|
||||
onSessionExpired();
|
||||
} else if (isMountedRef.current) {
|
||||
setError("Couldn't reach Simba. Check your connection and try again.");
|
||||
} else if (
|
||||
isMountedRef.current &&
|
||||
abortControllerRef.current === abortController
|
||||
) {
|
||||
setError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Couldn't reach Simba. Reload this conversation to resume.",
|
||||
);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
// A stream that ends (or is cancelled) mid-call leaves rows that
|
||||
// will never get a tool_end; stop their spinners.
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.speaker === "tool" && m.status === "running")
|
||||
? prev.map((m) =>
|
||||
m.speaker === "tool" && m.status === "running"
|
||||
? { ...m, status: "error", endedAt: Date.now() }
|
||||
: m,
|
||||
)
|
||||
: prev,
|
||||
);
|
||||
if (abortControllerRef.current === abortController) {
|
||||
setIsLoading(false);
|
||||
setStreaming(false);
|
||||
setThinking(false);
|
||||
// Refresh after every send so the just-used conversation floats to
|
||||
// the top of the sidebar (sorted by last activity) and picks up the
|
||||
// auto-generated title on the first message.
|
||||
refreshConversations();
|
||||
}
|
||||
streamingActiveRef.current = false;
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
pendingAttachment,
|
||||
@@ -406,12 +551,21 @@ export function useChat({
|
||||
refreshConversations,
|
||||
onSessionExpired,
|
||||
updateMessages,
|
||||
followRun,
|
||||
],
|
||||
);
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
const stopGeneration = useCallback(async () => {
|
||||
stopRequestedRef.current = true;
|
||||
const run = activeRunRef.current;
|
||||
if (!run) return; // creation will send cancellation once its id arrives
|
||||
try {
|
||||
await conversationService.cancelRun(run);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Could not stop generation",
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearError = useCallback(() => setError(null), []);
|
||||
@@ -442,6 +596,7 @@ export function useChat({
|
||||
setMessages: updateMessages,
|
||||
isLoading,
|
||||
streaming,
|
||||
reconnecting,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingAttachment,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
import { historyService } from "../api/historyService";
|
||||
import type { Conversation } from "./useConversations";
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -14,82 +15,134 @@ export function useConversationBrowser() {
|
||||
const [search, setSearchValue] = useState("");
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
// Guards against a slow earlier request overwriting a newer one.
|
||||
const requestIdRef = useRef(0);
|
||||
const nextOffsetRef = useRef(0);
|
||||
const searchRef = useRef(search);
|
||||
searchRef.current = search;
|
||||
|
||||
const fetchPage = useCallback(
|
||||
async (offset: number, searchTerm: string) => {
|
||||
const fetchPage = useCallback(async (offset: number, searchTerm: string) => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations({
|
||||
const fetched = await historyService.list({
|
||||
limit: PAGE_SIZE + 1,
|
||||
offset,
|
||||
search: searchTerm || undefined,
|
||||
search: searchTerm,
|
||||
});
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
|
||||
const page = fetched
|
||||
.slice(0, PAGE_SIZE)
|
||||
.map((c) => ({ id: c.id, title: c.name }));
|
||||
const page = fetched.slice(0, PAGE_SIZE).map((c) => ({
|
||||
id: c.id,
|
||||
title: c.name,
|
||||
pinned: c.pinned,
|
||||
snippet: c.snippet,
|
||||
matchMessageId: c.match_message_id,
|
||||
}));
|
||||
nextOffsetRef.current = offset + page.length;
|
||||
setHasMore(fetched.length > PAGE_SIZE);
|
||||
setItems((prev) => (offset === 0 ? page : [...prev, ...page]));
|
||||
setItems((prev) =>
|
||||
offset === 0
|
||||
? page
|
||||
: [
|
||||
...prev,
|
||||
...page.filter((c) => !prev.some((old) => old.id === c.id)),
|
||||
],
|
||||
);
|
||||
} catch (err) {
|
||||
if (requestId === requestIdRef.current) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
setError("Unable to load conversations. Please try again.");
|
||||
}
|
||||
} finally {
|
||||
if (requestId === requestIdRef.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
}, []);
|
||||
|
||||
// Debounce search changes; reset to the first page on each new term.
|
||||
useEffect(() => {
|
||||
setHasMore(false);
|
||||
setItems([]);
|
||||
const handle = setTimeout(() => fetchPage(0, search), 250);
|
||||
return () => clearTimeout(handle);
|
||||
return () => {
|
||||
clearTimeout(handle);
|
||||
++requestIdRef.current;
|
||||
};
|
||||
}, [search, fetchPage]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (loading || !hasMore) return;
|
||||
fetchPage(items.length, search);
|
||||
}, [loading, hasMore, items.length, search, fetchPage]);
|
||||
fetchPage(nextOffsetRef.current, search);
|
||||
}, [loading, hasMore, search, fetchPage]);
|
||||
|
||||
const renameConversation = useCallback(
|
||||
async (id: string, title: string) => {
|
||||
const trimmed = title.trim();
|
||||
if (!trimmed) return;
|
||||
const previous = items;
|
||||
const requestId = requestIdRef.current;
|
||||
setItems((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, title: trimmed } : c)),
|
||||
);
|
||||
try {
|
||||
await conversationService.renameConversation(id, trimmed);
|
||||
await fetchPage(0, searchRef.current);
|
||||
} catch (err) {
|
||||
console.error("Failed to rename conversation:", err);
|
||||
setItems(previous);
|
||||
setError("Unable to rename conversation.");
|
||||
if (requestId === requestIdRef.current) setItems(previous);
|
||||
}
|
||||
},
|
||||
[items],
|
||||
[items, fetchPage],
|
||||
);
|
||||
|
||||
const deleteConversation = useCallback(
|
||||
async (id: string) => {
|
||||
const previous = items;
|
||||
const requestId = requestIdRef.current;
|
||||
setItems((prev) => prev.filter((c) => c.id !== id));
|
||||
try {
|
||||
await conversationService.deleteConversation(id);
|
||||
await fetchPage(0, searchRef.current);
|
||||
} catch (err) {
|
||||
console.error("Failed to delete conversation:", err);
|
||||
setItems(previous);
|
||||
setError("Unable to delete conversation.");
|
||||
if (requestId === requestIdRef.current) setItems(previous);
|
||||
}
|
||||
},
|
||||
[items],
|
||||
[items, fetchPage],
|
||||
);
|
||||
|
||||
const pinConversation = useCallback(
|
||||
async (id: string, pinned: boolean) => {
|
||||
try {
|
||||
await historyService.pin(id, pinned);
|
||||
await fetchPage(0, searchRef.current);
|
||||
} catch {
|
||||
setError("Unable to change pin. Please try again.");
|
||||
}
|
||||
},
|
||||
[fetchPage],
|
||||
);
|
||||
|
||||
const exportConversation = useCallback(
|
||||
async (id: string, format: "json" | "markdown") => {
|
||||
try {
|
||||
await historyService.export(id, format);
|
||||
} catch {
|
||||
setError("Unable to export conversation. Please try again.");
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
error,
|
||||
pinConversation,
|
||||
exportConversation,
|
||||
items,
|
||||
search,
|
||||
setSearch: setSearchValue,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useState, useCallback, useEffect, useMemo } from "react";
|
||||
import { useMatch, useNavigate } from "react-router-dom";
|
||||
import { historyService } from "../api/historyService";
|
||||
import { conversationService } from "../api/conversationService";
|
||||
|
||||
export type Conversation = {
|
||||
title: string;
|
||||
id: string;
|
||||
pinned?: boolean;
|
||||
snippet?: string;
|
||||
matchMessageId?: string | null;
|
||||
};
|
||||
|
||||
// The sidebar/drawer only shows the most recent conversations; the full list
|
||||
@@ -20,10 +24,12 @@ export function useConversations() {
|
||||
|
||||
const refreshConversations = useCallback(async () => {
|
||||
try {
|
||||
const fetched = await conversationService.getAllConversations({
|
||||
const fetched = await historyService.list({
|
||||
limit: SIDEBAR_LIMIT,
|
||||
});
|
||||
setConversations(fetched.map((c) => ({ id: c.id, title: c.name })));
|
||||
setConversations(
|
||||
fetched.map((c) => ({ id: c.id, title: c.name, pinned: c.pinned })),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("Failed to load conversations:", err);
|
||||
}
|
||||
@@ -48,7 +54,13 @@ export function useConversations() {
|
||||
const createConversation = useCallback(async (): Promise<Conversation> => {
|
||||
const newConv = await conversationService.createConversation();
|
||||
const conversation = { title: newConv.name, id: newConv.id };
|
||||
setConversations((prev) => [conversation, ...prev]);
|
||||
setConversations((prev) =>
|
||||
[
|
||||
...prev.filter((c) => c.pinned),
|
||||
conversation,
|
||||
...prev.filter((c) => !c.pinned),
|
||||
].slice(0, SIDEBAR_LIMIT),
|
||||
);
|
||||
navigate(`/c/${conversation.id}`);
|
||||
return conversation;
|
||||
}, [navigate]);
|
||||
@@ -90,7 +102,17 @@ export function useConversations() {
|
||||
[conversations, conversationId, navigate],
|
||||
);
|
||||
|
||||
const pinConversation = useCallback(
|
||||
async (id: string, pinned: boolean) => {
|
||||
await historyService.pin(id, pinned);
|
||||
await refreshConversations();
|
||||
},
|
||||
[refreshConversations],
|
||||
);
|
||||
|
||||
return {
|
||||
pinConversation,
|
||||
exportConversation: historyService.export,
|
||||
conversations,
|
||||
selectedConversation,
|
||||
createConversation,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import {
|
||||
scheduledMessageService,
|
||||
type ScheduledMessage,
|
||||
@@ -7,19 +7,34 @@ import {
|
||||
export function useScheduledMessages() {
|
||||
const [messages, setMessages] = useState<ScheduledMessage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const request = useRef(0);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
scheduledMessageService
|
||||
.list()
|
||||
.then(setMessages)
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
const refresh = useCallback(async () => {
|
||||
const current = ++request.current;
|
||||
try {
|
||||
const result = await scheduledMessageService.list();
|
||||
if (current === request.current) {
|
||||
setMessages(result);
|
||||
setError("");
|
||||
}
|
||||
} catch (err) {
|
||||
if (current === request.current) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load scheduled messages");
|
||||
}
|
||||
} finally {
|
||||
if (current === request.current) setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
void refresh();
|
||||
const timer = setInterval(() => void refresh(), 10_000);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
++request.current;
|
||||
};
|
||||
}, [refresh]);
|
||||
|
||||
return { messages, loading, refresh };
|
||||
return { messages, loading, error, refresh };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
const fs = require("node:fs");
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const ts = require("typescript");
|
||||
const React = require("react");
|
||||
const { renderToStaticMarkup } = require("react-dom/server");
|
||||
for (const ext of [".ts", ".tsx"]) {
|
||||
require.extensions[ext] = (mod, filename) => {
|
||||
const { outputText } = ts.transpileModule(
|
||||
fs.readFileSync(filename, "utf8"),
|
||||
{
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
mod._compile(outputText, filename);
|
||||
};
|
||||
}
|
||||
const { historyService } = require("../src/api/historyService.ts");
|
||||
const { userService } = require("../src/api/userService.ts");
|
||||
const { ConversationList } = require("../src/components/ConversationList.tsx");
|
||||
const { MemoryPanel } = require("../src/components/MemoryPanel.tsx");
|
||||
|
||||
test("history requests use authenticated fetch, encode search and send explicit pin boolean", async () => {
|
||||
const calls = [];
|
||||
const original = userService.fetchWithAuth;
|
||||
userService.fetchWithAuth = async (...args) => {
|
||||
calls.push(args);
|
||||
return new Response(JSON.stringify([]), { status: 200 });
|
||||
};
|
||||
try {
|
||||
await historyService.list({ search: "cats & dogs", limit: 21, offset: 20 });
|
||||
const query = new URL(calls[0][0], "https://example.test").searchParams;
|
||||
assert.equal(query.get("search"), "cats & dogs");
|
||||
assert.equal(query.get("offset"), "20");
|
||||
await historyService.pin("id", false);
|
||||
assert.equal(calls[1][0], "/api/history/conversations/id/pin");
|
||||
assert.equal(calls[1][1].method, "PATCH");
|
||||
assert.deepEqual(JSON.parse(calls[1][1].body), { pinned: false });
|
||||
await historyService.correctMemory("id", "correct fact");
|
||||
assert.deepEqual(JSON.parse(calls[2][1].body), { content: "correct fact" });
|
||||
userService.fetchWithAuth = async (...args) => {
|
||||
calls.push(args);
|
||||
return new Response(null, { status: 204 });
|
||||
};
|
||||
assert.equal(await historyService.forgetMemory("id"), undefined);
|
||||
assert.equal(calls[3][1].method, "DELETE");
|
||||
} finally {
|
||||
userService.fetchWithAuth = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("failed exports and memory updates are surfaced", async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
userService.fetchWithAuth = async () => new Response(null, { status: 404 });
|
||||
try {
|
||||
await assert.rejects(
|
||||
historyService.export("foreign", "json"),
|
||||
/Unable to export/,
|
||||
);
|
||||
await assert.rejects(
|
||||
historyService.correctMemory("foreign", "content"),
|
||||
/Unable to update/,
|
||||
);
|
||||
} finally {
|
||||
userService.fetchWithAuth = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("conversation results render escaped snippets, pinned status and keyboard reachable actions", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(ConversationList, {
|
||||
conversations: [
|
||||
{
|
||||
id: "owned",
|
||||
title: "Cats",
|
||||
pinned: true,
|
||||
snippet: "<script>cats</script>",
|
||||
},
|
||||
],
|
||||
onSelectConversation() {},
|
||||
onCreateNewConversation() {},
|
||||
onPinConversation() {},
|
||||
onExportConversation() {},
|
||||
variant: "light",
|
||||
}),
|
||||
);
|
||||
assert.match(html, /aria-label="Pinned"/);
|
||||
assert.match(html, /aria-label="Actions for Cats"/);
|
||||
assert.match(html, /<script>cats<\/script>/);
|
||||
assert.doesNotMatch(html, /<script>/);
|
||||
});
|
||||
|
||||
test("memory panel exposes its purpose and loading status", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(MemoryPanel, { onClose() {} }),
|
||||
);
|
||||
assert.match(html, /aria-label="Saved memories"/);
|
||||
assert.match(html, /Correct facts Simba remembers/);
|
||||
assert.match(html, /role="status"/);
|
||||
assert.match(html, />Close</);
|
||||
});
|
||||
@@ -0,0 +1,166 @@
|
||||
const fs = require("node:fs");
|
||||
const assert = require("node:assert/strict");
|
||||
const test = require("node:test");
|
||||
const ts = require("typescript");
|
||||
const React = require("react");
|
||||
const { renderToStaticMarkup } = require("react-dom/server");
|
||||
|
||||
// Same renderer harness as history-management.test.cjs; no new packages.
|
||||
for (const ext of [".ts", ".tsx"]) {
|
||||
require.extensions[ext] = (mod, filename) => {
|
||||
const { outputText } = ts.transpileModule(fs.readFileSync(filename, "utf8"), {
|
||||
compilerOptions: {
|
||||
target: ts.ScriptTarget.ES2020,
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
jsx: ts.JsxEmit.ReactJSX,
|
||||
esModuleInterop: true,
|
||||
},
|
||||
});
|
||||
mod._compile(outputText, filename);
|
||||
};
|
||||
}
|
||||
const { userService } = require("../src/api/userService.ts");
|
||||
const { scheduledMessageService: service, canRetryScheduledMessage } = require("../src/api/scheduledMessageService.ts");
|
||||
const hook = require("../src/hooks/useScheduledMessages.ts");
|
||||
const { ScheduledMessagesPanel, DeliveryHistory, DELIVERY_STATUS_LABELS } = require("../src/components/ScheduledMessagesPanel.tsx");
|
||||
|
||||
function message(status, retryable = false, recurrence = "none") {
|
||||
return {
|
||||
id: "message-id", series_id: "series-id", retryable, status, recurrence,
|
||||
recipient: "person@example.test", channel: "email", content: "Hello", subject: "Hello",
|
||||
scheduled_at: "2026-10-13T12:00:00Z", use_agent: false, error_message: null,
|
||||
created_at: "2026-10-12T12:00:00Z", updated_at: "2026-10-12T12:00:00Z",
|
||||
};
|
||||
}
|
||||
function panelHtml(messages, error = "") {
|
||||
const original = hook.useScheduledMessages;
|
||||
hook.useScheduledMessages = () => ({ messages, loading: false, error, refresh: async () => {} });
|
||||
try {
|
||||
return renderToStaticMarkup(React.createElement(ScheduledMessagesPanel, { onClose() {} }));
|
||||
} finally {
|
||||
hook.useScheduledMessages = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("retry requires both failed status and explicit retryable permission", () => {
|
||||
for (const status of ["pending", "processing", "sent", "uncertain", "cancelled"]) {
|
||||
assert.equal(canRetryScheduledMessage(message(status, true)), false);
|
||||
}
|
||||
assert.equal(canRetryScheduledMessage(message("failed", true)), true);
|
||||
assert.equal(canRetryScheduledMessage(message("failed", false)), false);
|
||||
assert.equal(canRetryScheduledMessage({ status: "failed" }), false);
|
||||
});
|
||||
|
||||
test("renderer labels all statuses and limits retry/delete actions", () => {
|
||||
for (const status of Object.keys(DELIVERY_STATUS_LABELS).filter((s) => s !== "sending")) {
|
||||
const html = panelHtml([message(status, true)]);
|
||||
assert.ok(html.includes(DELIVERY_STATUS_LABELS[status]));
|
||||
assert.equal(html.includes('aria-label="Retry failed delivery"'), status === "failed");
|
||||
assert.equal(html.includes('aria-label="Delete message"'), ["pending", "cancelled"].includes(status));
|
||||
assert.match(html, /Delivery history for person@example.test/);
|
||||
}
|
||||
assert.doesNotMatch(panelHtml([message("failed", false)]), /aria-label="Retry failed delivery"/);
|
||||
});
|
||||
|
||||
test("uncertain delivery explains reconciliation before resend", () => {
|
||||
const html = panelHtml([message("uncertain")]);
|
||||
assert.match(html, /Delivery may already have occurred/);
|
||||
assert.match(html, /Reconcile with the provider or recipient before any resend/);
|
||||
assert.doesNotMatch(html, /aria-label="Retry failed delivery"/);
|
||||
});
|
||||
|
||||
test("recurring actions explain series cancellation and deletion", () => {
|
||||
assert.match(panelHtml([message("pending", false, "daily")]), /Delete occurrence and cancel series/);
|
||||
for (const status of ["pending", "failed", "processing", "sent", "uncertain"]) {
|
||||
assert.match(panelHtml([message(status, false, "weekly")]), /aria-label="Cancel series"/);
|
||||
}
|
||||
});
|
||||
|
||||
test("list errors are surfaced with a refresh action", () => {
|
||||
const html = panelHtml([], "Permission denied");
|
||||
assert.match(html, /role="alert"/);
|
||||
assert.match(html, /Permission denied/);
|
||||
assert.match(html, /Refresh messages/);
|
||||
assert.doesNotMatch(html, /No scheduled messages yet/);
|
||||
});
|
||||
|
||||
test("delivery history renders every attempt, timing, status and escaped errors", () => {
|
||||
const html = renderToStaticMarkup(React.createElement(DeliveryHistory, { deliveries: [{
|
||||
id: "delivery", run_id: "run-1", status: "uncertain", error: "provider timeout", retryable: false,
|
||||
attempts: [
|
||||
{ id: "a1", status: "failed", error: "<script>rejected</script>", started_at: "2026-10-12T12:00:00Z", finished_at: "2026-10-12T12:01:00Z" },
|
||||
{ id: "a2", status: "sending", error: null, started_at: "2026-10-12T12:02:00Z", finished_at: null },
|
||||
],
|
||||
}] }));
|
||||
assert.match(html, /Run: run-1/);
|
||||
assert.match(html, /Attempt 1: Failed/);
|
||||
assert.match(html, /Attempt 2: Sending/);
|
||||
assert.match(html, /Started:/);
|
||||
assert.match(html, /Finished:/);
|
||||
assert.match(html, /Not finished/);
|
||||
assert.match(html, /provider timeout/);
|
||||
assert.match(html, /<script>rejected<\/script>/);
|
||||
assert.match(html, /uncertain deliveries cannot be retried/);
|
||||
assert.match(renderToStaticMarkup(React.createElement(DeliveryHistory, { deliveries: [] })), /No delivery attempts yet/);
|
||||
});
|
||||
|
||||
test("service retains CRUD and uses authenticated history GET and explicit retry POST", async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
const calls = [];
|
||||
userService.fetchWithAuth = async (...args) => {
|
||||
calls.push(args);
|
||||
return new Response(JSON.stringify({ id: "result" }), { status: 200 });
|
||||
};
|
||||
try {
|
||||
await service.list();
|
||||
const create = { recipient: "person", channel: "imessage", content: "Hello", scheduled_at: "2026-10-13T12:00:00Z" };
|
||||
await service.create(create);
|
||||
await service.update("id", { scheduled_at: create.scheduled_at });
|
||||
await service.update("id", { status: "cancelled" });
|
||||
await service.deliveries("id/with space");
|
||||
await service.retry("id/with space");
|
||||
await service.remove("id");
|
||||
assert.equal(calls[0][0], "/api/scheduled-messages/");
|
||||
assert.equal(calls[1][1].method, "POST");
|
||||
assert.deepEqual(JSON.parse(calls[1][1].body), create);
|
||||
assert.equal(calls[2][1].method, "PUT");
|
||||
assert.deepEqual(JSON.parse(calls[3][1].body), { status: "cancelled" });
|
||||
assert.deepEqual(calls[4], ["/api/scheduled-messages/id%2Fwith%20space/deliveries"]);
|
||||
assert.deepEqual(calls[5], ["/api/scheduled-messages/id%2Fwith%20space/retry", { method: "POST" }]);
|
||||
assert.equal(calls[6][1].method, "DELETE");
|
||||
userService.fetchWithAuth = async () => new Response(null, { status: 204 });
|
||||
assert.equal(await service.remove("id"), undefined);
|
||||
} finally {
|
||||
userService.fetchWithAuth = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("PUT cannot reset a failed delivery to pending even through untyped input", async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
let called = false;
|
||||
userService.fetchWithAuth = async () => { called = true; throw new Error("Unexpected request"); };
|
||||
try {
|
||||
await assert.rejects(service.update("failed", { status: "pending", scheduled_at: "2026-10-13T12:00:00Z" }), /explicit retry endpoint/);
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
userService.fetchWithAuth = original;
|
||||
}
|
||||
});
|
||||
|
||||
test("all service failures surface backend details and non-JSON fallbacks", async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
try {
|
||||
for (const invoke of [() => service.list(), () => service.create({}), () => service.update("id", {}), () => service.remove("id"), () => service.deliveries("id"), () => service.retry("id")]) {
|
||||
userService.fetchWithAuth = async () => new Response(JSON.stringify({ error: "Only safe failed deliveries can be retried" }), { status: 409 });
|
||||
await assert.rejects(invoke(), /Only safe failed deliveries/);
|
||||
userService.fetchWithAuth = async () => new Response("Unavailable", { status: 503 });
|
||||
await assert.rejects(invoke(), /Failed to/);
|
||||
}
|
||||
userService.fetchWithAuth = async () => new Response(JSON.stringify({ status: "uncertain", error_message: "Delivery requires reconciliation" }), { status: 409 });
|
||||
await assert.rejects(service.retry("id"), /requires reconciliation/);
|
||||
userService.fetchWithAuth = async () => new Response(JSON.stringify({ status: "processing", error_message: null }), { status: 409 });
|
||||
await assert.rejects(service.retry("id"), /Failed to retry scheduled message \(processing\)/);
|
||||
} finally {
|
||||
userService.fetchWithAuth = original;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require('node:fs');
|
||||
const assert = require('node:assert/strict');
|
||||
const test = require('node:test');
|
||||
const ts = require('typescript');
|
||||
require.extensions['.ts'] = (mod, filename) => {
|
||||
const { outputText } = ts.transpileModule(fs.readFileSync(filename, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, esModuleInterop: true },
|
||||
});
|
||||
mod._compile(outputText, filename);
|
||||
};
|
||||
const { conversationService } = require('../src/api/conversationService.ts');
|
||||
const { userService } = require('../src/api/userService.ts');
|
||||
const run = { id: 'run', conversation_id: 'conv', status: 'running', query: 'hi' };
|
||||
function stream(chunks) {
|
||||
return new Response(new ReadableStream({ start(controller) {
|
||||
for (const chunk of chunks) controller.enqueue(new TextEncoder().encode(chunk));
|
||||
controller.close();
|
||||
}}));
|
||||
}
|
||||
|
||||
test('SSE parser accepts split CRLF, ids, comments, UTF8, and DONE; ignores duplicate replay', async () => {
|
||||
const events = [], cursors = [];
|
||||
const response = stream([': ping\r', '\n\r\nid: 1\r\ndata: {"type":"content","delta":"猫"}\r', '\n\r\nid: 1\ndata: {"type":"content","delta":"duplicate"}\n\nid: 2\ndata: [DONE]\n\n']);
|
||||
assert.equal(await conversationService._readSSEStream(response, e => events.push(e), id => cursors.push(id)), true);
|
||||
assert.deepEqual(events, [{ type: 'content', delta: '猫' }]);
|
||||
assert.deepEqual(cursors, [1]);
|
||||
});
|
||||
|
||||
test('premature EOF is recoverable and resume uses last successfully handled cursor', async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
const calls = [], events = [], connection = [];
|
||||
userService.fetchWithAuth = async (url) => {
|
||||
calls.push(url);
|
||||
return calls.length === 1 ? stream(['id: 1\ndata: {"type":"content","delta":"first"}\n\n']) :
|
||||
stream(['id: 1\ndata: {"type":"content","delta":"duplicate"}\n\nid: 2\ndata: {"type":"response","message":"answer"}\n\nid: 3\ndata: [DONE]\n\n']);
|
||||
};
|
||||
try {
|
||||
await conversationService.resumeRun(run, e => events.push(e), new AbortController().signal, v => connection.push(v));
|
||||
assert.match(calls[1], /cursor=1$/);
|
||||
assert.deepEqual(events.map(e => e.type), ['content', 'response']);
|
||||
assert.deepEqual(connection, [false, true, false]);
|
||||
} finally { userService.fetchWithAuth = original; }
|
||||
});
|
||||
|
||||
test('ambiguous creation retry reuses idempotency key; discovery and cancellation are authenticated', async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
const calls = [];
|
||||
userService.fetchWithAuth = async (url, init) => {
|
||||
calls.push([url, init]);
|
||||
if (calls.length === 1) throw new TypeError('network failed');
|
||||
return new Response(JSON.stringify(url.endsWith('/runs') && init?.method !== 'POST' ? { run } : run));
|
||||
};
|
||||
try {
|
||||
const body = { query: 'hi', conversation_id: 'conv', request_id: 'stable' };
|
||||
await conversationService.createRun(body);
|
||||
assert.equal(calls[0][1].body, calls[1][1].body);
|
||||
assert.equal((await conversationService.discoverRun('conv')).id, 'run');
|
||||
await conversationService.cancelRun(run);
|
||||
assert.equal(calls.at(-1)[0], '/api/conversation/conv/runs/run/cancel');
|
||||
assert.equal(calls.at(-1)[1].method, 'POST');
|
||||
} finally { userService.fetchWithAuth = original; }
|
||||
});
|
||||
|
||||
test('detach never issues cancellation and terminal authorization failures do not retry', async () => {
|
||||
const original = userService.fetchWithAuth;
|
||||
const calls = [];
|
||||
userService.fetchWithAuth = async (url) => { calls.push(url); return new Response('', { status: 404 }); };
|
||||
try {
|
||||
await assert.rejects(conversationService.resumeRun(run, () => {}, new AbortController().signal), /resume/);
|
||||
assert.equal(calls.length, 1);
|
||||
const controller = new AbortController(); controller.abort();
|
||||
await assert.rejects(conversationService.resumeRun(run, () => {}, controller.signal), { name: 'AbortError' });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.ok(calls.every(url => !url.includes('/cancel')));
|
||||
} finally { userService.fetchWithAuth = original; }
|
||||
});
|
||||
+4
-2
@@ -40,8 +40,10 @@ if ! docker compose --profile "$NEW" up -d --wait "raggr-$NEW"; then
|
||||
fi
|
||||
|
||||
if [ -n "$OLD" ]; then
|
||||
echo "==> raggr-$NEW healthy; stopping raggr-$OLD (30s grace for in-flight streams)..."
|
||||
docker compose --profile "$OLD" stop -t 30 "raggr-$OLD"
|
||||
# Allow application run draining plus cleanup; override for operational needs.
|
||||
STOP_SECONDS=${DEPLOY_STOP_SECONDS:-$(( ${CHAT_RUN_DRAIN_SECONDS:-1800} + 60 ))}
|
||||
echo "==> raggr-$NEW healthy; draining raggr-$OLD (${STOP_SECONDS}s grace)..."
|
||||
docker compose --profile "$OLD" stop -t "$STOP_SECONDS" "raggr-$OLD"
|
||||
docker compose --profile "$OLD" rm -f "raggr-$OLD"
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from langchain_core.messages import AIMessage
|
||||
from quart import Quart
|
||||
|
||||
from blueprints.conversation import runtime
|
||||
from metrics import AGENT_REQUESTS_TOTAL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"channel", ["web", "email", "imessage", "whatsapp", "scheduled", "cron"]
|
||||
)
|
||||
@pytest.mark.parametrize("admin", [False, True])
|
||||
async def test_channels_share_identity_memories_permissions_and_summary(
|
||||
monkeypatch, channel, admin
|
||||
):
|
||||
app = Quart(__name__)
|
||||
user = SimpleNamespace(id="owner", is_admin=lambda: admin)
|
||||
app.main_agent = SimpleNamespace(
|
||||
ainvoke=AsyncMock(return_value={"messages": [AIMessage(content="answer")]}),
|
||||
aget_state=AsyncMock(return_value=SimpleNamespace(values={"messages": []})),
|
||||
)
|
||||
app.admin_agent = SimpleNamespace(
|
||||
ainvoke=AsyncMock(
|
||||
return_value={"messages": [AIMessage(content="admin answer")]}
|
||||
),
|
||||
aget_state=AsyncMock(return_value=SimpleNamespace(values={"messages": []})),
|
||||
)
|
||||
summary = AIMessage(content="Saved constraints")
|
||||
app.conversation_summary_store = SimpleNamespace(
|
||||
update=AsyncMock(return_value=SimpleNamespace(as_message=lambda: summary))
|
||||
)
|
||||
prompt = AsyncMock(return_value="Remember the budget")
|
||||
monkeypatch.setattr(runtime, "build_system_prompt_with_memories", prompt)
|
||||
before = AGENT_REQUESTS_TOTAL.labels(channel=channel, result="success")._value.get()
|
||||
async with app.app_context():
|
||||
response = await runtime.invoke_agent_for_user(
|
||||
user, "What is my budget?", "thread", channel=channel
|
||||
)
|
||||
agent = app.admin_agent if admin else app.main_agent
|
||||
assert response["messages"][-1].content == ("admin answer" if admin else "answer")
|
||||
config = agent.ainvoke.call_args.kwargs["config"]["configurable"]
|
||||
assert config["user_id"] == "owner"
|
||||
assert config["is_admin"] is admin
|
||||
assert config["thread_id"] == "thread"
|
||||
assert config["channel"] == channel
|
||||
assert config["system_prompt"] == "Remember the budget"
|
||||
assert config["conversation_summary"] is summary
|
||||
assert config["request_id"]
|
||||
prompt.assert_awaited_once_with("owner", query="What is my budget?")
|
||||
assert (
|
||||
AGENT_REQUESTS_TOTAL.labels(channel=channel, result="success")._value.get()
|
||||
== before + 1
|
||||
)
|
||||
|
||||
|
||||
async def test_anonymous_invocation_is_rejected():
|
||||
with pytest.raises(ValueError, match="authenticated user"):
|
||||
await runtime.invoke_agent_for_user(None, "hello", "thread")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"error, outcome",
|
||||
[(RuntimeError("failed"), "error"), (asyncio.CancelledError(), "cancelled")],
|
||||
)
|
||||
async def test_shared_invocation_measures_failed_and_cancelled_generation(
|
||||
error, outcome
|
||||
):
|
||||
agent = SimpleNamespace(ainvoke=AsyncMock(side_effect=error))
|
||||
turn = runtime.PreparedTurn(
|
||||
agent,
|
||||
{"messages": []},
|
||||
{
|
||||
"configurable": {
|
||||
"channel": "web",
|
||||
"request_id": "test",
|
||||
"thread_id": "t",
|
||||
"user_id": "u",
|
||||
}
|
||||
},
|
||||
)
|
||||
before = AGENT_REQUESTS_TOTAL.labels(channel="web", result=outcome)._value.get()
|
||||
with pytest.raises(type(error)):
|
||||
await runtime.invoke_prepared(turn)
|
||||
assert (
|
||||
AGENT_REQUESTS_TOTAL.labels(channel="web", result=outcome)._value.get()
|
||||
== before + 1
|
||||
)
|
||||
|
||||
|
||||
async def test_concurrent_turns_on_one_thread_keep_every_exchange(
|
||||
monkeypatch, tmp_path
|
||||
):
|
||||
import aiosqlite
|
||||
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
|
||||
monkeypatch.setattr(
|
||||
runtime, "build_system_prompt_with_memories", AsyncMock(return_value="sys")
|
||||
)
|
||||
async with aiosqlite.connect(tmp_path / "checkpoints.db") as conn:
|
||||
saver = AsyncSqliteSaver(conn)
|
||||
await saver.setup()
|
||||
|
||||
async def answer(state):
|
||||
# Yield so an unserialized second turn would read stale history.
|
||||
await asyncio.sleep(0.05)
|
||||
return {
|
||||
"messages": [
|
||||
AIMessage(content="reply " + state["messages"][-1].content)
|
||||
]
|
||||
}
|
||||
|
||||
graph = StateGraph(MessagesState)
|
||||
graph.add_node("answer", answer)
|
||||
graph.add_edge(START, "answer")
|
||||
graph.add_edge("answer", END)
|
||||
agent = graph.compile(checkpointer=saver)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.main_agent = app.admin_agent = agent
|
||||
user = SimpleNamespace(id="owner", is_admin=lambda: False)
|
||||
async with app.app_context():
|
||||
await asyncio.gather(
|
||||
*[
|
||||
runtime.invoke_agent_for_user(user, q, "same-thread")
|
||||
for q in ("first", "second")
|
||||
]
|
||||
)
|
||||
state = await agent.aget_state(
|
||||
{"configurable": {"thread_id": "same-thread"}}
|
||||
)
|
||||
contents = [m.content for m in state.values["messages"]]
|
||||
assert sorted(contents) == ["first", "reply first", "reply second", "second"]
|
||||
@@ -23,6 +23,7 @@ async def db():
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.conversation.run_models",
|
||||
"blueprints.artifacts.models",
|
||||
]
|
||||
},
|
||||
@@ -343,12 +344,12 @@ class TestStreamQuery:
|
||||
"data": {"output": {"messages": [AIMessage(content="Done")]}},
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(conv, "get_agent_for", lambda u: _FakeAgent(events))
|
||||
monkeypatch.setattr(conv.runtime, "get_agent_for", lambda u: _FakeAgent(events))
|
||||
|
||||
async def _prompt(user_id, query=""):
|
||||
return "system"
|
||||
|
||||
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
|
||||
monkeypatch.setattr(conv.runtime, "build_system_prompt_with_memories", _prompt)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
|
||||
@@ -0,0 +1,482 @@
|
||||
import asyncio
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from quart import Quart
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints.conversation import runs
|
||||
from blueprints.conversation.models import Conversation, ConversationMessage
|
||||
from blueprints.conversation.run_models import ChatRun, ChatRunEvent
|
||||
from blueprints.users.models import User
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def db(tmp_path):
|
||||
await Tortoise.init(
|
||||
db_url=f"sqlite://{tmp_path / 'runs.sqlite'}",
|
||||
modules={
|
||||
"models": [
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.conversation.run_models",
|
||||
"blueprints.users.models",
|
||||
]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
app = Quart(__name__)
|
||||
app.config["RUN_TEST_DB_URL"] = f"sqlite://{tmp_path / 'runs.sqlite'}"
|
||||
user = await User.create(username="owner", email="owner@test")
|
||||
other = await User.create(username="other", email="other@test")
|
||||
conv = await Conversation.create(name="Chat", user=user)
|
||||
yield app, user, other, conv
|
||||
await runs.drain_runs(app, 0)
|
||||
runs._draining_apps.discard(id(app))
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
async def reserved(user, conv, **kwargs):
|
||||
return await ChatRun.create(
|
||||
owner=user,
|
||||
conversation=conv,
|
||||
request_key=str(uuid.uuid4()),
|
||||
payload={"query": "hello"},
|
||||
active_key=str(conv.id),
|
||||
worker_token=uuid.uuid4(),
|
||||
lease_until=runs.now() + timedelta(seconds=30),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
async def wait_terminal(run):
|
||||
for _ in range(200):
|
||||
run = await ChatRun.get(id=run.id)
|
||||
if run.status != "running":
|
||||
return run
|
||||
await asyncio.sleep(0.01)
|
||||
pytest.fail("run did not terminate")
|
||||
|
||||
|
||||
async def test_order_cursor_isolation_and_partial_persisted_once(db):
|
||||
app, user, other, conv = db
|
||||
run = await reserved(user, conv)
|
||||
await runs.append(run.id, run.worker_token, {"type": "content", "delta": "one"})
|
||||
await runs.append(run.id, run.worker_token, {"type": "content", "delta": "two"})
|
||||
await runs.finish(run.id, "completed")
|
||||
await runs.finish(run.id, "cancelled")
|
||||
events = await ChatRunEvent.filter(run=run).order_by("sequence")
|
||||
assert [e.sequence for e in events] == [1, 2, 3]
|
||||
assert (
|
||||
await ConversationMessage.filter(conversation=conv, speaker="simba").count()
|
||||
== 1
|
||||
)
|
||||
assert (await ConversationMessage.get(conversation=conv)).text == "onetwo"
|
||||
assert await runs.owned_run(other.id, conv.id, run.id) is None
|
||||
foreign = await Conversation.create(name="other", user=user)
|
||||
assert await runs.owned_run(user.id, foreign.id, run.id) is None
|
||||
async with app.app_context():
|
||||
response = await runs.replay_response(run, 1)
|
||||
text = (await response.get_data()).decode()
|
||||
assert "id: 2" in text and "id: 1" not in text
|
||||
assert text.index("id: 2") < text.index("[DONE]")
|
||||
|
||||
|
||||
async def test_atomic_idempotency_and_concurrent_slot(db, monkeypatch):
|
||||
app, user, other, conv = db
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def fake(*args):
|
||||
await gate.wait()
|
||||
|
||||
monkeypatch.setattr(runs, "_work", fake)
|
||||
data = {"conversation_id": str(conv.id), "query": "hi", "request_id": "same"}
|
||||
results = await asyncio.gather(
|
||||
*(runs.create_run(app, user.id, data) for _ in range(4))
|
||||
)
|
||||
assert len({str(run.id) for run, _ in results}) == 1
|
||||
assert sum(created for _, created in results) == 1
|
||||
assert await ChatRun.all().count() == 1
|
||||
with pytest.raises(runs.RunConflict, match="different input"):
|
||||
await runs.create_run(app, user.id, {**data, "query": "changed"})
|
||||
with pytest.raises(runs.RunConflict, match="already active"):
|
||||
await runs.create_run(app, user.id, {**data, "request_id": "another"})
|
||||
with pytest.raises(runs.RunConflict) as err:
|
||||
await runs.create_run(app, other.id, data)
|
||||
assert err.value.status == 404
|
||||
gate.set()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
|
||||
async def test_expired_restart_never_regenerates_and_releases_slot(db):
|
||||
app, user, _, conv = db
|
||||
run = await reserved(user, conv)
|
||||
await runs.append(run.id, run.worker_token, {"type": "content", "delta": "partial"})
|
||||
await ChatRun.filter(id=run.id).update(
|
||||
lease_until=runs.now() - timedelta(seconds=1)
|
||||
)
|
||||
# Close/reopen the real file database, discarding process-local ORM state.
|
||||
await Tortoise.close_connections()
|
||||
await Tortoise.init(
|
||||
db_url=app.config["RUN_TEST_DB_URL"],
|
||||
modules={
|
||||
"models": [
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.conversation.run_models",
|
||||
"blueprints.users.models",
|
||||
]
|
||||
},
|
||||
)
|
||||
await runs.expire_runs(str(conv.id))
|
||||
await runs.expire_runs(str(conv.id))
|
||||
latest = await ChatRun.get(id=run.id)
|
||||
assert latest.status == "interrupted" and latest.active_key is None
|
||||
assert await ConversationMessage.filter(conversation=conv).count() == 1
|
||||
assert (await ChatRunEvent.filter(run=run).order_by("sequence"))[1].payload[
|
||||
"type"
|
||||
] == "error"
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await runs.append(
|
||||
run.id, run.worker_token, {"type": "content", "delta": "zombie"}
|
||||
)
|
||||
|
||||
|
||||
async def test_background_context_detach_and_cross_process_cancel(db, monkeypatch):
|
||||
app, user, _, conv = db
|
||||
gate = asyncio.Event()
|
||||
started = asyncio.Event()
|
||||
from quart import current_app
|
||||
import blueprints.conversation as conversation
|
||||
|
||||
async def fake(data, user, conv, save_user, save_answer):
|
||||
assert current_app._get_current_object() is app
|
||||
await save_user("hi", None)
|
||||
yield 'data: {"type":"content","delta":"partial"}\n\n'
|
||||
started.set()
|
||||
await gate.wait()
|
||||
await save_answer("complete")
|
||||
yield 'data: {"type":"response","message":"complete"}\n\n'
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
monkeypatch.setattr(conversation, "_chat_run_stream", fake)
|
||||
run, _ = await runs.create_run(
|
||||
app,
|
||||
user.id,
|
||||
{"conversation_id": str(conv.id), "query": "hi", "request_id": "turn"},
|
||||
)
|
||||
await started.wait()
|
||||
async with app.app_context():
|
||||
response = await runs.replay_response(run, 0)
|
||||
subscriber = response.response.__aiter__()
|
||||
frame = ""
|
||||
while "partial" not in frame: # skip keepalives until the delta is written
|
||||
frame = await asyncio.wait_for(subscriber.__anext__(), 2)
|
||||
await subscriber.aclose() # browser disconnect
|
||||
assert (await ChatRun.get(id=run.id)).status == "running"
|
||||
# Same write made by another process, no local task cancellation API involved.
|
||||
await ChatRun.filter(id=run.id).update(cancel_requested=True)
|
||||
latest = await wait_terminal(run)
|
||||
assert latest.status == "cancelled"
|
||||
assert (
|
||||
await ConversationMessage.filter(conversation=conv, speaker="user").count() == 1
|
||||
)
|
||||
assert (
|
||||
await ConversationMessage.filter(conversation=conv, speaker="simba").count()
|
||||
== 1
|
||||
)
|
||||
assert (
|
||||
await ConversationMessage.get(conversation=conv, speaker="simba")
|
||||
).text == "partial"
|
||||
|
||||
|
||||
async def test_completed_answer_once_and_drain(db, monkeypatch):
|
||||
app, user, _, conv = db
|
||||
import blueprints.conversation as conversation
|
||||
|
||||
async def fake(data, user, conv, save_user, save_answer):
|
||||
await save_user("hi", None)
|
||||
yield 'data: {"type":"content","delta":"answer"}\n\n'
|
||||
await save_answer("answer")
|
||||
yield 'data: {"type":"response","message":"answer"}\n\n'
|
||||
|
||||
monkeypatch.setattr(conversation, "_chat_run_stream", fake)
|
||||
run, _ = await runs.create_run(
|
||||
app,
|
||||
user.id,
|
||||
{"conversation_id": str(conv.id), "query": "hi", "request_id": "turn"},
|
||||
)
|
||||
await runs.drain_runs(app, 2)
|
||||
latest = await ChatRun.get(id=run.id)
|
||||
assert latest.status == "completed"
|
||||
assert (
|
||||
await ConversationMessage.filter(conversation=conv, speaker="simba").count()
|
||||
== 1
|
||||
)
|
||||
with pytest.raises(runs.RunConflict) as err:
|
||||
await runs.create_run(
|
||||
app, user.id, {"conversation_id": str(conv.id), "query": "new"}
|
||||
)
|
||||
assert err.value.status == 503
|
||||
|
||||
|
||||
async def test_authenticated_routes_validate_owner_conversation_cursor_and_cancel(db):
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
|
||||
app, owner, foreign, conv = db
|
||||
app.config.update(
|
||||
TESTING=True, JWT_SECRET_KEY="chat-runs-test-secret-at-least-32-characters"
|
||||
)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(runs.runs_blueprint)
|
||||
async with app.app_context():
|
||||
headers = {
|
||||
"Authorization": "Bearer " + create_access_token(identity=str(owner.id))
|
||||
}
|
||||
other_headers = {
|
||||
"Authorization": "Bearer " + create_access_token(identity=str(foreign.id))
|
||||
}
|
||||
client = app.test_client()
|
||||
run = await reserved(owner, conv)
|
||||
base = f"/api/conversation/{conv.id}/runs/{run.id}"
|
||||
assert (await client.get(base + "/events")).status_code == 401
|
||||
assert (
|
||||
await client.get(base + "/events", headers=other_headers)
|
||||
).status_code == 404
|
||||
assert (
|
||||
await client.post(base + "/cancel", headers=other_headers)
|
||||
).status_code == 404
|
||||
assert not (await ChatRun.get(id=run.id)).cancel_requested
|
||||
for cursor in ("-1", "bad", "999"):
|
||||
assert (
|
||||
await client.get(base + f"/events?cursor={cursor}", headers=headers)
|
||||
).status_code == 400
|
||||
assert (await client.post(base + "/cancel", headers=headers)).status_code == 200
|
||||
assert (await ChatRun.get(id=run.id)).cancel_requested
|
||||
assert (
|
||||
await client.get(f"/api/conversation/{conv.id}/runs", headers=other_headers)
|
||||
).status_code == 404
|
||||
discovery = await (
|
||||
await client.get(f"/api/conversation/{conv.id}/runs", headers=headers)
|
||||
).get_json()
|
||||
assert discovery["run"]["id"] == str(run.id)
|
||||
assert (
|
||||
await client.post(
|
||||
"/api/conversation/runs",
|
||||
headers=other_headers,
|
||||
json={"conversation_id": str(conv.id), "query": "hi"},
|
||||
)
|
||||
).status_code == 404
|
||||
assert (
|
||||
await client.post("/api/conversation/runs", headers=headers, json=[])
|
||||
).status_code == 400
|
||||
|
||||
|
||||
async def test_real_shared_runtime_legacy_endpoint_persists_and_replays(
|
||||
db, monkeypatch
|
||||
):
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from langchain_core.messages import AIMessageChunk, AIMessage
|
||||
import blueprints.conversation as conversation
|
||||
|
||||
app, owner, _, conv = db
|
||||
|
||||
class Agent:
|
||||
async def astream_events(self, payload, version, config):
|
||||
assert config["configurable"]["user_id"] == str(owner.id)
|
||||
yield {"event": "on_chat_model_start", "run_id": "llm", "name": "model"}
|
||||
yield {
|
||||
"event": "on_chat_model_stream",
|
||||
"run_id": "llm",
|
||||
"data": {"chunk": AIMessageChunk(content="answer")},
|
||||
}
|
||||
yield {
|
||||
"event": "on_chat_model_end",
|
||||
"run_id": "llm",
|
||||
"data": {"output": AIMessage(content="answer")},
|
||||
}
|
||||
yield {
|
||||
"event": "on_chain_end",
|
||||
"data": {"output": {"messages": [AIMessage(content="answer")]}},
|
||||
}
|
||||
|
||||
app.main_agent = Agent()
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
monkeypatch.setattr(
|
||||
conversation.runtime,
|
||||
"build_system_prompt_with_memories",
|
||||
AsyncMock(return_value="system"),
|
||||
)
|
||||
app.config.update(
|
||||
TESTING=True, JWT_SECRET_KEY="chat-runs-test-secret-at-least-32-characters"
|
||||
)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conversation.conversation_blueprint)
|
||||
app.register_blueprint(runs.runs_blueprint)
|
||||
async with app.app_context():
|
||||
headers = {
|
||||
"Authorization": "Bearer " + create_access_token(identity=str(owner.id))
|
||||
}
|
||||
response = await app.test_client().post(
|
||||
"/api/conversation/stream-query",
|
||||
headers=headers,
|
||||
json={"conversation_id": str(conv.id), "query": "hi", "request_id": "legacy"},
|
||||
)
|
||||
body = (await response.get_data()).decode()
|
||||
assert (
|
||||
response.status_code == 200
|
||||
and '"type": "response"' in body
|
||||
and "[DONE]" in body
|
||||
)
|
||||
run = await ChatRun.get(request_key="legacy")
|
||||
assert run.status == "completed"
|
||||
assert (
|
||||
await ConversationMessage.filter(conversation=conv, speaker="simba").count()
|
||||
== 1
|
||||
)
|
||||
replay = await app.test_client().get(
|
||||
f"/api/conversation/{conv.id}/runs/{run.id}/events?cursor=0", headers=headers
|
||||
)
|
||||
assert (await replay.get_data()).decode() == body.replace(": keepalive\n\n", "")
|
||||
|
||||
|
||||
async def test_nonstream_query_validates_input_and_respects_active_run(db):
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
|
||||
from blueprints.conversation import conversation_blueprint
|
||||
|
||||
app, owner, _, conv = db
|
||||
app.config.update(
|
||||
TESTING=True, JWT_SECRET_KEY="chat-runs-test-secret-at-least-32-characters"
|
||||
)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(conversation_blueprint)
|
||||
async with app.app_context():
|
||||
headers = {
|
||||
"Authorization": "Bearer " + create_access_token(identity=str(owner.id))
|
||||
}
|
||||
client = app.test_client()
|
||||
for body in (
|
||||
["not", "an", "object"],
|
||||
{"conversation_id": str(conv.id), "query": 123},
|
||||
{"conversation_id": str(conv.id), "query": " "},
|
||||
):
|
||||
resp = await client.post("/api/conversation/query", headers=headers, json=body)
|
||||
assert resp.status_code == 400, body
|
||||
resp = await client.post(
|
||||
"/api/conversation/query",
|
||||
headers=headers,
|
||||
json={"conversation_id": ["x"], "query": "hi"},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
# A durable run owns the checkpoint thread: reject before saving anything.
|
||||
await reserved(owner, conv)
|
||||
resp = await client.post(
|
||||
"/api/conversation/query",
|
||||
headers=headers,
|
||||
json={"conversation_id": str(conv.id), "query": "hi"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert await ConversationMessage.filter(conversation=conv).count() == 0
|
||||
|
||||
|
||||
async def test_deltas_coalesce_and_local_subscribers_wake_immediately(db, monkeypatch):
|
||||
app, user, _, conv = db
|
||||
import blueprints.conversation as conversation
|
||||
|
||||
release = asyncio.Event()
|
||||
|
||||
async def fake(data, user, conv, save_user, save_answer):
|
||||
await save_user("hi", None)
|
||||
for word in ("one ", "two ", "three"):
|
||||
yield f'data: {{"type":"content","delta":"{word}"}}\n\n'
|
||||
yield 'data: {"type":"tool_start","tool":"t"}\n\n'
|
||||
await release.wait()
|
||||
await save_answer("one two three")
|
||||
yield 'data: {"type":"response","message":"one two three"}\n\n'
|
||||
|
||||
monkeypatch.setattr(conversation, "_chat_run_stream", fake)
|
||||
monkeypatch.setattr(runs, "POLL_SECONDS", 30) # only a wakeup can be fast
|
||||
run, _ = await runs.create_run(
|
||||
app, user.id, {"conversation_id": str(conv.id), "query": "hi"}
|
||||
)
|
||||
async with app.app_context():
|
||||
response = await runs.replay_response(run, 0)
|
||||
frames = response.response.__aiter__()
|
||||
seen = []
|
||||
while not any('"tool_start"' in f for f in seen):
|
||||
seen.append(await asyncio.wait_for(frames.__anext__(), 2))
|
||||
release.set()
|
||||
while "[DONE]" not in seen[-1]:
|
||||
seen.append(await asyncio.wait_for(frames.__anext__(), 2))
|
||||
deltas = [
|
||||
e.payload["delta"]
|
||||
for e in await ChatRunEvent.filter(run=run).order_by("sequence")
|
||||
if e.payload["type"] == "content"
|
||||
]
|
||||
# First delta is written immediately; the burst behind it shares one write.
|
||||
assert "".join(deltas) == "one two three"
|
||||
assert len(deltas) == 2
|
||||
await wait_terminal(run)
|
||||
|
||||
|
||||
async def _content_deltas(run):
|
||||
return [
|
||||
e.payload["delta"]
|
||||
for e in await ChatRunEvent.filter(run=run).order_by("sequence")
|
||||
if e.payload["type"] == "content"
|
||||
]
|
||||
|
||||
|
||||
async def test_buffered_delta_flushes_while_model_is_silent(db, monkeypatch):
|
||||
app, user, _, conv = db
|
||||
import blueprints.conversation as conversation
|
||||
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def fake(data, user, conv, save_user, save_answer):
|
||||
await save_user("hi", None)
|
||||
yield 'data: {"type":"content","delta":"a"}\n\n'
|
||||
yield 'data: {"type":"content","delta":"b"}\n\n'
|
||||
await gate.wait() # e.g. the model is writing tool-call arguments
|
||||
yield 'data: {"type":"response","message":"ab"}\n\n'
|
||||
|
||||
monkeypatch.setattr(conversation, "_chat_run_stream", fake)
|
||||
monkeypatch.setattr(runs, "FLUSH_SECONDS", 0.05)
|
||||
run, _ = await runs.create_run(
|
||||
app, user.id, {"conversation_id": str(conv.id), "query": "hi"}
|
||||
)
|
||||
for _ in range(100):
|
||||
if "".join(await _content_deltas(run)) == "ab":
|
||||
break
|
||||
await asyncio.sleep(0.01)
|
||||
assert "".join(await _content_deltas(run)) == "ab"
|
||||
gate.set()
|
||||
await wait_terminal(run)
|
||||
|
||||
|
||||
async def test_stop_keeps_buffered_text_in_saved_answer(db, monkeypatch):
|
||||
app, user, _, conv = db
|
||||
import blueprints.conversation as conversation
|
||||
|
||||
buffered = asyncio.Event()
|
||||
|
||||
async def fake(data, user, conv, save_user, save_answer):
|
||||
await save_user("hi", None)
|
||||
yield 'data: {"type":"content","delta":"first "}\n\n'
|
||||
yield 'data: {"type":"content","delta":"tail"}\n\n'
|
||||
buffered.set()
|
||||
await asyncio.Event().wait()
|
||||
|
||||
monkeypatch.setattr(conversation, "_chat_run_stream", fake)
|
||||
monkeypatch.setattr(runs, "FLUSH_SECONDS", 60) # "tail" stays buffered
|
||||
run, _ = await runs.create_run(
|
||||
app, user.id, {"conversation_id": str(conv.id), "query": "hi"}
|
||||
)
|
||||
await buffered.wait()
|
||||
await ChatRun.filter(id=run.id).update(cancel_requested=True)
|
||||
assert (await wait_terminal(run)).status == "cancelled"
|
||||
answer = await ConversationMessage.get(conversation=conv, speaker="simba")
|
||||
assert answer.text == "first tail"
|
||||
@@ -263,10 +263,14 @@ class TestUploadRoute:
|
||||
|
||||
class TestStreamQueryFileKey:
|
||||
async def test_rejects_file_key_from_other_conversation(self, app, headers, s3):
|
||||
from blueprints.conversation import runs
|
||||
|
||||
add = AsyncMock()
|
||||
owned = AsyncMock(return_value=SimpleNamespace(id=CONV_ID))
|
||||
with (
|
||||
patch.object(conv.blueprints.users.models.User, "get", AsyncMock()),
|
||||
patch.object(conv, "add_message_to_conversation", add),
|
||||
patch.object(runs.Conversation, "get_or_none", owned),
|
||||
patch.object(runs.ChatRun, "create", AsyncMock()) as create,
|
||||
patch.object(runs, "add_message_to_conversation", add),
|
||||
):
|
||||
resp = await app.test_client().post(
|
||||
"/api/conversation/stream-query",
|
||||
@@ -278,6 +282,7 @@ class TestStreamQueryFileKey:
|
||||
},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
create.assert_not_awaited()
|
||||
add.assert_not_awaited()
|
||||
|
||||
async def test_load_attached_file_recovers_name_and_text(self):
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
"""Real SQLite tests: ownership, joined pagination, exports and malformed requests."""
|
||||
|
||||
import base64
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints.conversation.history import history_blueprint, list_owned_conversations
|
||||
from blueprints.conversation.models import (
|
||||
Conversation,
|
||||
ConversationMessage,
|
||||
Speaker,
|
||||
UserMemory,
|
||||
)
|
||||
from blueprints.users.models import User
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def history_app():
|
||||
await Tortoise.init(
|
||||
db_url="sqlite://:memory:",
|
||||
modules={
|
||||
"models": ["blueprints.users.models", "blueprints.conversation.models"]
|
||||
},
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
app = Quart(__name__)
|
||||
app.config.update(
|
||||
TESTING=True,
|
||||
JWT_SECRET_KEY="history-test-secret-at-least-thirty-two-characters",
|
||||
)
|
||||
JWTManager(app)
|
||||
app.register_blueprint(history_blueprint)
|
||||
owner = await User.create(username="owner", email="owner@example.com")
|
||||
foreign = await User.create(username="foreign", email="foreign@example.com")
|
||||
async with app.app_context():
|
||||
headers = {
|
||||
"Authorization": f"Bearer {create_access_token(identity=str(owner.id))}"
|
||||
}
|
||||
try:
|
||||
yield app.test_client(), headers, owner, foreign
|
||||
finally:
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
async def message(conversation, text, speaker=Speaker.USER, **kwargs):
|
||||
return await ConversationMessage.create(
|
||||
conversation=conversation, text=text, speaker=speaker, **kwargs
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_unique_pagination_and_owner_isolation(history_app):
|
||||
client, headers, owner, foreign = history_app
|
||||
a = await Conversation.create(user=owner, name="ordinary")
|
||||
b = await Conversation.create(user=owner, name="needle title", pinned=True)
|
||||
secret = await Conversation.create(user=foreign, name="needle foreign", pinned=True)
|
||||
await Conversation.create(name="needle unowned")
|
||||
for _ in range(5):
|
||||
await message(a, "many needle message matches")
|
||||
await message(secret, "needle secret")
|
||||
first = await list_owned_conversations(owner.id, "needle", 1, 0)
|
||||
second = await list_owned_conversations(owner.id, "needle", 1, 1)
|
||||
assert [first[0]["id"], second[0]["id"]] == [str(b.id), str(a.id)]
|
||||
assert await list_owned_conversations(owner.id, "needle", 1, 2) == []
|
||||
assert "needle" in second[0]["snippet"]
|
||||
assert second[0]["match_message_id"]
|
||||
response = await client.get(
|
||||
"/api/history/conversations?search=NEEDLE&limit=10", headers=headers
|
||||
)
|
||||
assert {row["id"] for row in await response.get_json()} == {str(a.id), str(b.id)}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pin_owner_only_boolean_and_recency(history_app):
|
||||
client, headers, owner, foreign = history_app
|
||||
own = await Conversation.create(user=owner, name="own")
|
||||
other = await Conversation.create(user=foreign, name="other")
|
||||
for identifier in (other.id, "malformed"):
|
||||
response = await client.patch(
|
||||
f"/api/history/conversations/{identifier}/pin",
|
||||
headers=headers,
|
||||
json={"pinned": True},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
for body in ({}, {"pinned": "false"}, {"pinned": 1}, [], None):
|
||||
response = await client.patch(
|
||||
f"/api/history/conversations/{own.id}/pin", headers=headers, json=body
|
||||
)
|
||||
assert response.status_code == 400
|
||||
original = own.updated_at
|
||||
for pinned in (True, False):
|
||||
response = await client.patch(
|
||||
f"/api/history/conversations/{own.id}/pin",
|
||||
headers=headers,
|
||||
json={"pinned": pinned},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await own.refresh_from_db()
|
||||
assert own.pinned is pinned
|
||||
assert own.updated_at == original
|
||||
await other.refresh_from_db()
|
||||
assert not other.pinned
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_display_and_attachment_provenance(history_app):
|
||||
client, headers, owner, foreign = history_app
|
||||
own = await Conversation.create(user=owner, name='quoted "title"')
|
||||
other = await Conversation.create(user=foreign, name="FOREIGN SECRET")
|
||||
await message(other, "FOREIGN CONTENT")
|
||||
await message(own, "hello\n\n📎 notes.md", image_key="foreign/private/secret.png")
|
||||
await message(own, "[web_search result]\nHIDDEN RAW TOOL CONTEXT", Speaker.TOOL)
|
||||
await message(own, "answer", Speaker.SIMBA)
|
||||
await message(own, "malformed tool payload\nHIDDEN MORE", Speaker.TOOL)
|
||||
response = await client.get(
|
||||
f"/api/history/conversations/{own.id}/export", headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
text = json.dumps(data)
|
||||
for secret in ("HIDDEN RAW", "HIDDEN MORE", "FOREIGN", "foreign/private"):
|
||||
assert secret not in text
|
||||
assert data["messages"][1]["text"] == "[web_search result]"
|
||||
assert data["messages"][1]["provenance"] == "displayed_tool_header"
|
||||
assert data["messages"][0]["attachments"] == [
|
||||
{"kind": "file", "recorded_name": "notes.md", "contents_included": False},
|
||||
{"kind": "image", "stored_reference_present": True, "contents_included": False},
|
||||
]
|
||||
assert "attachment; filename=" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
md = await client.get(
|
||||
f"/api/history/conversations/{own.id}/export?format=markdown", headers=headers
|
||||
)
|
||||
markdown = await md.get_data(as_text=True)
|
||||
assert md.status_code == 200 and "text/markdown" in md.content_type
|
||||
assert "📎 notes.md" in markdown and "Raw tool context" in markdown
|
||||
assert "HIDDEN" not in markdown
|
||||
for identifier in (other.id, "bad-uuid"):
|
||||
assert (
|
||||
await client.get(
|
||||
f"/api/history/conversations/{identifier}/export", headers=headers
|
||||
)
|
||||
).status_code == 404
|
||||
assert (
|
||||
await client.get(
|
||||
f"/api/history/conversations/{own.id}/export?format=html", headers=headers
|
||||
)
|
||||
).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_owner_only_correction_forget_and_validation(history_app):
|
||||
client, headers, owner, foreign = history_app
|
||||
own = await UserMemory.create(user=owner, content="old fact")
|
||||
other = await UserMemory.create(user=foreign, content="FOREIGN MEMORY")
|
||||
rows = await (await client.get("/api/history/memories", headers=headers)).get_json()
|
||||
assert [row["id"] for row in rows] == [str(own.id)]
|
||||
for identifier in (other.id, "invalid"):
|
||||
for method in (client.patch, client.delete):
|
||||
assert (
|
||||
await method(
|
||||
f"/api/history/memories/{identifier}",
|
||||
headers=headers,
|
||||
json={"content": "stolen"},
|
||||
)
|
||||
).status_code == 404
|
||||
for body in (
|
||||
{},
|
||||
[],
|
||||
{"content": None},
|
||||
{"content": 3},
|
||||
{"content": " "},
|
||||
{"content": "x" * 10001},
|
||||
):
|
||||
assert (
|
||||
await client.patch(
|
||||
f"/api/history/memories/{own.id}", headers=headers, json=body
|
||||
)
|
||||
).status_code == 400
|
||||
response = await client.patch(
|
||||
f"/api/history/memories/{own.id}",
|
||||
headers=headers,
|
||||
json={"content": " corrected fact "},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())["content"] == "corrected fact"
|
||||
await own.refresh_from_db()
|
||||
assert own.content == "corrected fact"
|
||||
assert (
|
||||
await client.delete(f"/api/history/memories/{own.id}", headers=headers)
|
||||
).status_code == 204
|
||||
assert await UserMemory.get_or_none(id=own.id) is None
|
||||
await other.refresh_from_db()
|
||||
assert other.content == "FOREIGN MEMORY"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_memory_pagination(history_app):
|
||||
client, headers, owner, _ = history_app
|
||||
for i in range(5):
|
||||
await UserMemory.create(user=owner, content=f"fact {i}")
|
||||
first = await (
|
||||
await client.get("/api/history/memories?limit=2&offset=0", headers=headers)
|
||||
).get_json()
|
||||
second = await (
|
||||
await client.get("/api/history/memories?limit=2&offset=2", headers=headers)
|
||||
).get_json()
|
||||
assert len(first) == len(second) == 2
|
||||
assert {r["id"] for r in first}.isdisjoint(r["id"] for r in second)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_authentication_and_malformed_pagination(history_app):
|
||||
client, headers, owner, _ = history_app
|
||||
own = await Conversation.create(user=owner, name="own")
|
||||
memory = await UserMemory.create(user=owner, content="fact")
|
||||
for method, path in (
|
||||
(client.get, "/conversations"),
|
||||
(client.get, "/memories"),
|
||||
(client.get, f"/conversations/{own.id}/export"),
|
||||
(client.patch, f"/conversations/{own.id}/pin"),
|
||||
(client.patch, f"/memories/{memory.id}"),
|
||||
(client.delete, f"/memories/{memory.id}"),
|
||||
):
|
||||
assert (await method("/api/history" + path)).status_code == 401
|
||||
for path in ("conversations", "memories"):
|
||||
for query in ("limit=0", "limit=101", "offset=-1", "limit=oops", "offset=1.5"):
|
||||
assert (
|
||||
await client.get(f"/api/history/{path}?{query}", headers=headers)
|
||||
).status_code == 400
|
||||
assert (
|
||||
await client.get(
|
||||
"/api/history/conversations?search=" + "x" * 501, headers=headers
|
||||
)
|
||||
).status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_adds_pinned_and_records_state(history_app):
|
||||
migration_path = (
|
||||
Path(__file__).parents[2]
|
||||
/ "migrations/models/20_20261012231017_application_foundation.py"
|
||||
)
|
||||
spec = importlib.util.spec_from_file_location("history_migration", migration_path)
|
||||
migration = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(migration)
|
||||
sql = await migration.upgrade(None)
|
||||
assert 'ALTER TABLE "conversations" ADD "pinned" BOOL NOT NULL DEFAULT False' in sql
|
||||
state = json.loads(zlib.decompress(base64.b64decode(migration.MODELS_STATE)))
|
||||
assert any(
|
||||
field["name"] == "pinned" and field["default"] is False
|
||||
for field in state["models.Conversation"]["data_fields"]
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints import integrations
|
||||
from blueprints.users.models import User
|
||||
from blueprints.ynab import tools
|
||||
from blueprints.ynab.sync import tool_data_freshness
|
||||
|
||||
|
||||
async def test_stale_ynab_status_hides_provider_error(monkeypatch):
|
||||
monkeypatch.setattr(integrations, "ynab_configured", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
integrations,
|
||||
"_current_state",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
last_synced_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
last_error="Authorization: secret-provider-key",
|
||||
)
|
||||
),
|
||||
)
|
||||
result = await integrations._ynab_status()
|
||||
assert result["status"] == "stale"
|
||||
assert result["last_success_at"]
|
||||
assert "secret-provider-key" not in str(result)
|
||||
|
||||
|
||||
async def test_fresh_ynab_status_is_ready(monkeypatch):
|
||||
monkeypatch.setattr(integrations, "ynab_configured", lambda: True)
|
||||
monkeypatch.setattr(
|
||||
integrations,
|
||||
"_current_state",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(
|
||||
last_synced_at=datetime.now(timezone.utc), last_error=None
|
||||
)
|
||||
),
|
||||
)
|
||||
assert (await integrations._ynab_status())["status"] == "ready"
|
||||
|
||||
|
||||
async def test_stale_tool_data_warns_model_and_carries_artifact_provenance():
|
||||
@tools.ynab_tool_errors
|
||||
async def result():
|
||||
tool_data_freshness.set(
|
||||
{"system": "ynab", "as_of": "2026-10-01T00:00:00+00:00", "stale": True}
|
||||
)
|
||||
return "Total: $100", {"kind": "ynab.summary", "data": {"total": 100}}
|
||||
|
||||
text, artifact = await result()
|
||||
assert "cached budget data is stale" in text
|
||||
assert "2026-10-01" in text
|
||||
assert artifact["source_ref"]["stale"] is True
|
||||
assert artifact["data"] == {"total": 100}
|
||||
|
||||
|
||||
async def test_tool_freshness_does_not_leak_from_previous_call():
|
||||
tool_data_freshness.set({"system": "ynab", "as_of": "old", "stale": True})
|
||||
|
||||
@tools.ynab_tool_errors
|
||||
async def result():
|
||||
return "Dates must be YYYY-MM-DD."
|
||||
|
||||
assert await result() == "Dates must be YYYY-MM-DD."
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def status_app(monkeypatch):
|
||||
await Tortoise.init(
|
||||
db_url="sqlite://:memory:", modules={"models": ["blueprints.users.models"]}
|
||||
)
|
||||
await Tortoise.generate_schemas()
|
||||
user = await User.create(username="owner", email="owner@example.com")
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(integrations.integrations_blueprint)
|
||||
monkeypatch.setattr(
|
||||
integrations,
|
||||
"integration_statuses",
|
||||
AsyncMock(
|
||||
return_value=[
|
||||
{
|
||||
"name": "YNAB",
|
||||
"status": "stale",
|
||||
"message": "Cached data",
|
||||
"last_success_at": None,
|
||||
}
|
||||
]
|
||||
),
|
||||
)
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity=str(user.id))
|
||||
yield app, token
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
async def test_status_requires_access_token(status_app):
|
||||
app, token = status_app
|
||||
assert (await app.test_client().get("/api/integrations/")).status_code == 401
|
||||
response = await app.test_client().get(
|
||||
"/api/integrations/", headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())["integrations"][0]["status"] == "stale"
|
||||
@@ -0,0 +1,690 @@
|
||||
"""Real SQLite ORM delivery claims; providers/runtime are replaced locally."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from quart import Quart
|
||||
from tortoise import Tortoise
|
||||
|
||||
from blueprints.cron.models import CronJob
|
||||
from blueprints.cron.scheduler import execute_job
|
||||
from blueprints.scheduled_messages import delivery
|
||||
from blueprints.scheduled_messages.models import (
|
||||
JobAttempt,
|
||||
JobDelivery,
|
||||
MessageChannel,
|
||||
MessageStatus,
|
||||
Recurrence,
|
||||
ScheduledMessage,
|
||||
)
|
||||
from blueprints.scheduled_messages.scheduler import (
|
||||
_schedule_next_occurrence,
|
||||
process_due_messages,
|
||||
process_message,
|
||||
run_id,
|
||||
)
|
||||
from blueprints.users.models import User
|
||||
|
||||
MODULES = [
|
||||
"blueprints.users.models",
|
||||
"blueprints.scheduled_messages.models",
|
||||
"blueprints.cron.models",
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db(tmp_path):
|
||||
path = tmp_path / "jobs.sqlite3"
|
||||
await Tortoise.init(db_url=f"sqlite://{path}", modules={"models": MODULES})
|
||||
await Tortoise.generate_schemas()
|
||||
yield path
|
||||
await Tortoise.close_connections()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def owner(db):
|
||||
return await User.create(username="owner", email="owner@example.com")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def message(owner):
|
||||
return await ScheduledMessage.create(
|
||||
recipient="recipient",
|
||||
channel=MessageChannel.EMAIL,
|
||||
subject="subject",
|
||||
content="prompt",
|
||||
scheduled_at=delivery.now() - timedelta(minutes=1),
|
||||
created_by=owner,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def job(owner):
|
||||
return await CronJob.create(
|
||||
name="cron",
|
||||
prompt="prompt",
|
||||
recipient="recipient",
|
||||
channel=MessageChannel.EMAIL,
|
||||
created_by=owner,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def providers(monkeypatch):
|
||||
send = AsyncMock()
|
||||
agent = AsyncMock(return_value="response")
|
||||
monkeypatch.setattr(delivery, "send", send)
|
||||
monkeypatch.setattr(delivery, "agent_content", agent)
|
||||
return send, agent
|
||||
|
||||
|
||||
async def test_atomic_concurrent_scheduled_claims(message, providers):
|
||||
results = await asyncio.gather(*[process_message(message) for _ in range(8)])
|
||||
assert results.count("sent") == 1
|
||||
assert providers[0].await_count == 1
|
||||
assert await JobAttempt.all().count() == 1
|
||||
await message.refresh_from_db()
|
||||
assert message.status == MessageStatus.SENT
|
||||
|
||||
|
||||
async def test_claims_across_independent_processes(db):
|
||||
# Both processes use their own SQLite connection and ORM registry.
|
||||
code = """
|
||||
import asyncio, sys
|
||||
from tortoise import Tortoise
|
||||
from blueprints.scheduled_messages.delivery import claim
|
||||
async def main():
|
||||
await Tortoise.init(db_url="sqlite://" + sys.argv[1], modules={"models": ["blueprints.users.models", "blueprints.scheduled_messages.models", "blueprints.cron.models"]})
|
||||
result = await claim("cron", "same-job", "same-run")
|
||||
print("won" if result else "lost")
|
||||
await Tortoise.close_connections()
|
||||
asyncio.run(main())
|
||||
"""
|
||||
|
||||
async def worker():
|
||||
p = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-c",
|
||||
code,
|
||||
str(db),
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
out, err = await p.communicate()
|
||||
assert p.returncode == 0, err.decode()
|
||||
return out.decode().strip()
|
||||
|
||||
assert sorted(await asyncio.gather(worker(), worker())) == ["lost", "won"]
|
||||
assert await JobAttempt.all().count() == 1
|
||||
|
||||
|
||||
async def test_safe_send_failure_explicit_retry(message, providers):
|
||||
providers[0].side_effect = delivery.SafeDeliveryError("rejected before send")
|
||||
assert await process_message(message) == "failed"
|
||||
assert (await process_due_messages())["sent"] == 0
|
||||
providers[0].side_effect = None
|
||||
await message.refresh_from_db()
|
||||
assert await process_message(message, retry=True) == "sent"
|
||||
assert [a.status for a in await JobAttempt.all().order_by("started_at")] == [
|
||||
"failed",
|
||||
"sent",
|
||||
]
|
||||
assert providers[0].await_count == 2
|
||||
|
||||
|
||||
async def test_ambiguous_provider_failure_never_resends(message, providers):
|
||||
providers[0].side_effect = TimeoutError("may have delivered")
|
||||
assert await process_message(message) == "uncertain"
|
||||
await message.refresh_from_db()
|
||||
assert await process_message(message, retry=True) == "skipped"
|
||||
await process_due_messages()
|
||||
assert providers[0].await_count == 1
|
||||
assert (await JobAttempt.first()).status == "uncertain"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("phase", ["processing", "sending"])
|
||||
async def test_abandoned_claim_is_uncertain(message, providers, phase):
|
||||
claimed = await delivery.claim("scheduled", message.id, run_id(message))
|
||||
if phase == "sending":
|
||||
await delivery.transition(claimed, "sending")
|
||||
await JobDelivery.filter(id=claimed.id).update(
|
||||
claimed_at=delivery.now() - timedelta(hours=2)
|
||||
)
|
||||
await ScheduledMessage.filter(id=message.id).update(status=MessageStatus.PROCESSING)
|
||||
await process_due_messages()
|
||||
await message.refresh_from_db()
|
||||
assert message.status == MessageStatus.UNCERTAIN
|
||||
assert (await JobAttempt.first()).status == "uncertain"
|
||||
providers[0].assert_not_awaited()
|
||||
|
||||
|
||||
async def test_crash_after_provider_send_never_resends(message, providers):
|
||||
class WorkerCrash(BaseException):
|
||||
pass
|
||||
|
||||
async def delivered_then_crash(*args):
|
||||
raise WorkerCrash()
|
||||
|
||||
providers[0].side_effect = delivered_then_crash
|
||||
with pytest.raises(WorkerCrash):
|
||||
await process_message(message)
|
||||
assert (await JobDelivery.first()).status == "sending"
|
||||
await JobDelivery.all().update(claimed_at=delivery.now() - timedelta(hours=2))
|
||||
await process_due_messages()
|
||||
await message.refresh_from_db()
|
||||
assert message.status == MessageStatus.UNCERTAIN
|
||||
assert providers[0].await_count == 1
|
||||
|
||||
|
||||
async def test_agent_failure_safe_retry_uses_owner(message, providers):
|
||||
message.use_agent = True
|
||||
await message.save()
|
||||
providers[1].side_effect = ValueError("no text")
|
||||
assert await process_message(message) == "failed"
|
||||
providers[0].assert_not_awaited()
|
||||
providers[1].side_effect = None
|
||||
await message.refresh_from_db()
|
||||
assert await process_message(message, retry=True) == "sent"
|
||||
args = providers[1].await_args.args
|
||||
assert args[0].id == message.created_by_id
|
||||
assert args[-1] == "scheduled"
|
||||
|
||||
|
||||
async def test_recurrence_deduplicates_and_skips_missed_occurrences(message, providers):
|
||||
message.recurrence = Recurrence.DAILY
|
||||
message.scheduled_at = delivery.now() - timedelta(days=3)
|
||||
await message.save()
|
||||
assert await process_message(message) == "sent"
|
||||
await asyncio.gather(*[_schedule_next_occurrence(message) for _ in range(5)])
|
||||
assert await ScheduledMessage.all().count() == 2
|
||||
successor = await ScheduledMessage.get(previous_occurrence_id=message.id)
|
||||
assert successor.scheduled_at > delivery.now()
|
||||
assert successor.series_id == message.id
|
||||
await process_due_messages()
|
||||
assert await ScheduledMessage.all().count() == 2
|
||||
|
||||
|
||||
async def test_cancelled_series_does_not_regenerate(message, providers):
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
await process_message(message)
|
||||
successor = await ScheduledMessage.get(previous_occurrence_id=message.id)
|
||||
await ScheduledMessage.filter(id=successor.id).update(
|
||||
status=MessageStatus.CANCELLED
|
||||
)
|
||||
await _schedule_next_occurrence(message)
|
||||
await process_due_messages()
|
||||
assert await ScheduledMessage.all().count() == 2
|
||||
assert providers[0].await_count == 1
|
||||
|
||||
|
||||
async def test_legacy_sent_rows_are_not_forked_into_new_series(owner, providers):
|
||||
# Rows sent before delivery claims existed have no successor links or
|
||||
# delivery records; the repair pass must leave them alone.
|
||||
common = dict(
|
||||
recipient="recipient",
|
||||
channel=MessageChannel.EMAIL,
|
||||
subject="subject",
|
||||
content="prompt",
|
||||
recurrence=Recurrence.DAILY,
|
||||
created_by=owner,
|
||||
)
|
||||
for days in (2, 1):
|
||||
await ScheduledMessage.create(
|
||||
**common,
|
||||
status=MessageStatus.SENT,
|
||||
scheduled_at=delivery.now() - timedelta(days=days),
|
||||
)
|
||||
await ScheduledMessage.create(
|
||||
**common, scheduled_at=delivery.now() + timedelta(hours=12)
|
||||
)
|
||||
await process_due_messages()
|
||||
assert await ScheduledMessage.filter(status=MessageStatus.PENDING).count() == 1
|
||||
assert await ScheduledMessage.all().count() == 3
|
||||
providers[0].assert_not_awaited()
|
||||
|
||||
|
||||
async def test_claimed_send_without_successor_is_repaired(message, providers):
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
assert await process_message(message) == "sent"
|
||||
# Simulate a crash after the send committed but before the successor existed.
|
||||
await ScheduledMessage.filter(previous_occurrence_id=message.id).delete()
|
||||
await process_due_messages()
|
||||
assert await ScheduledMessage.filter(previous_occurrence_id=message.id).exists()
|
||||
|
||||
|
||||
async def test_cancellation_during_agent_prevents_delivery(message, providers):
|
||||
message.use_agent = True
|
||||
await message.save()
|
||||
|
||||
async def cancel(*args):
|
||||
await ScheduledMessage.filter(id=message.id).update(
|
||||
status=MessageStatus.CANCELLED
|
||||
)
|
||||
return "response"
|
||||
|
||||
providers[1].side_effect = cancel
|
||||
assert await process_message(message) == "cancelled"
|
||||
providers[0].assert_not_awaited()
|
||||
assert (await JobDelivery.first()).status == "cancelled"
|
||||
|
||||
|
||||
async def test_duplicate_cron_triggers_scoped_to_run(job, providers):
|
||||
results = await asyncio.gather(
|
||||
*[execute_job(job, "occurrence-1") for _ in range(6)]
|
||||
)
|
||||
assert sum(bool(r.get("ok")) for r in results) == 1
|
||||
assert providers[0].await_count == 1
|
||||
assert await execute_job(job, "occurrence-2") == {
|
||||
"ok": True,
|
||||
"status": "sent",
|
||||
"error": None,
|
||||
}
|
||||
assert await JobAttempt.all().count() == 2
|
||||
assert providers[1].await_args.args[0].id == job.created_by_id
|
||||
assert providers[1].await_args.args[-1] == "cron"
|
||||
|
||||
|
||||
async def test_legacy_cron_has_no_implicit_admin(job, providers):
|
||||
job.created_by_id = None
|
||||
await job.save()
|
||||
result = await execute_job(job, "legacy")
|
||||
assert result["status"] == "failed"
|
||||
assert "explicit" in result["error"]
|
||||
providers[1].assert_not_awaited()
|
||||
providers[0].assert_not_awaited()
|
||||
|
||||
|
||||
async def test_cron_failure_retry_and_ambiguous_rejection(job, providers):
|
||||
providers[1].side_effect = ValueError("agent failed")
|
||||
assert (await execute_job(job, "run"))["status"] == "failed"
|
||||
assert (await execute_job(job, "run"))["skipped"]
|
||||
providers[1].side_effect = None
|
||||
providers[0].side_effect = TimeoutError("ambiguous")
|
||||
assert (await execute_job(job, "run", retry=True))["status"] == "uncertain"
|
||||
assert (await execute_job(job, "run", retry=True))["skipped"]
|
||||
assert await JobAttempt.all().count() == 2
|
||||
assert providers[0].await_count == 1
|
||||
|
||||
|
||||
async def test_disabled_cron_prevents_send_after_agent(job, providers):
|
||||
async def disable(*args):
|
||||
await CronJob.filter(id=job.id).update(enabled=False)
|
||||
return "response"
|
||||
|
||||
providers[1].side_effect = disable
|
||||
assert (await execute_job(job, "run"))["status"] == "cancelled"
|
||||
providers[0].assert_not_awaited()
|
||||
|
||||
|
||||
async def test_history_records_attempts(message, providers):
|
||||
await process_message(message)
|
||||
result = await delivery.history("scheduled", message.id)
|
||||
assert result[0]["status"] == "sent"
|
||||
assert not result[0]["retryable"]
|
||||
assert result[0]["attempts"][0]["finished_at"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def api(owner, monkeypatch):
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from blueprints.cron import cron_blueprint
|
||||
from blueprints.scheduled_messages import scheduled_messages_blueprint
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret" * 8
|
||||
JWTManager(app)
|
||||
app.register_blueprint(cron_blueprint)
|
||||
app.register_blueprint(scheduled_messages_blueprint)
|
||||
monkeypatch.setenv("CRON_SECRET", "cron-secret")
|
||||
owner.ldap_groups = ["lldap_admin"]
|
||||
await owner.save()
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity=str(owner.id))
|
||||
return app.test_client(), {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
async def test_trigger_auth_and_required_cron_identity(job, api, providers):
|
||||
client, _ = api
|
||||
url = f"/api/cron/{job.id}/trigger"
|
||||
assert (await client.post(url, json={"run_id": "run"})).status_code == 401
|
||||
assert (
|
||||
await client.post(url, headers={"X-Cron-Secret": "cron-secret"})
|
||||
).status_code == 400
|
||||
headers = {"X-Cron-Secret": "cron-secret", "Idempotency-Key": "run"}
|
||||
assert (await client.post(url, headers=headers)).status_code == 200
|
||||
assert (await (await client.post(url, headers=headers)).get_json())["skipped"]
|
||||
assert providers[0].await_count == 1
|
||||
|
||||
|
||||
async def test_admin_history_retry_and_failed_reschedule(message, providers, api):
|
||||
client, headers = api
|
||||
url = f"/api/scheduled-messages/{message.id}"
|
||||
providers[0].side_effect = delivery.SafeDeliveryError("not delivered")
|
||||
await process_message(message)
|
||||
response = await client.put(
|
||||
url,
|
||||
headers=headers,
|
||||
json={"scheduled_at": (delivery.now() + timedelta(hours=1)).isoformat()},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert (await response.get_json())["status"] == "failed"
|
||||
assert (await client.post(url + "/retry")).status_code == 401
|
||||
providers[0].side_effect = None
|
||||
assert (await client.post(url + "/retry", headers=headers)).status_code == 200
|
||||
data = await (await client.get(url + "/deliveries", headers=headers)).get_json()
|
||||
assert len(data[0]["attempts"]) == 2
|
||||
|
||||
|
||||
async def test_api_rejects_ambiguous_retry(message, providers, api):
|
||||
client, headers = api
|
||||
providers[0].side_effect = TimeoutError("unknown")
|
||||
await process_message(message)
|
||||
assert (
|
||||
await client.post(
|
||||
f"/api/scheduled-messages/{message.id}/retry", headers=headers
|
||||
)
|
||||
).status_code == 409
|
||||
assert providers[0].await_count == 1
|
||||
|
||||
|
||||
async def test_api_cancel_recurring_series(message, providers, api):
|
||||
client, headers = api
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
await process_message(message)
|
||||
successor = await ScheduledMessage.get(previous_occurrence_id=message.id)
|
||||
response = await client.put(
|
||||
f"/api/scheduled-messages/{message.id}",
|
||||
headers=headers,
|
||||
json={"status": "cancelled"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await successor.refresh_from_db()
|
||||
assert successor.status == MessageStatus.CANCELLED
|
||||
await process_due_messages()
|
||||
assert await ScheduledMessage.all().count() == 2
|
||||
|
||||
|
||||
async def test_cancel_keeps_delivered_status_and_stops_series(message, providers, api):
|
||||
client, headers = api
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
await process_message(message)
|
||||
response = await client.put(
|
||||
f"/api/scheduled-messages/{message.id}",
|
||||
headers=headers,
|
||||
json={"status": "cancelled"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await message.refresh_from_db()
|
||||
assert message.status == MessageStatus.SENT # delivered stays delivered
|
||||
successor = await ScheduledMessage.get(previous_occurrence_id=message.id)
|
||||
assert successor.status == MessageStatus.CANCELLED
|
||||
|
||||
|
||||
async def test_cancel_during_send_does_not_claim_cancelled(message, providers, api):
|
||||
client, headers = api
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
in_flight, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def slow_send(*args):
|
||||
in_flight.set()
|
||||
await release.wait()
|
||||
|
||||
providers[0].side_effect = slow_send
|
||||
task = asyncio.create_task(process_message(message))
|
||||
await in_flight.wait()
|
||||
response = await client.put(
|
||||
f"/api/scheduled-messages/{message.id}",
|
||||
headers=headers,
|
||||
json={"status": "cancelled"},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
release.set()
|
||||
assert await task == "sent"
|
||||
await message.refresh_from_db()
|
||||
assert message.status == MessageStatus.SENT
|
||||
# The series stops even though nothing pending existed when cancelled.
|
||||
await process_due_messages()
|
||||
assert not await ScheduledMessage.filter(
|
||||
status=MessageStatus.PENDING, deleted_at__isnull=True
|
||||
).exists()
|
||||
|
||||
|
||||
async def test_deleting_edited_occurrence_does_not_revive_series(
|
||||
message, providers, api
|
||||
):
|
||||
client, headers = api
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
await process_message(message)
|
||||
successor = await ScheduledMessage.get(previous_occurrence_id=message.id)
|
||||
url = f"/api/scheduled-messages/{successor.id}"
|
||||
assert (
|
||||
await client.put(url, headers=headers, json={"recurrence": "none"})
|
||||
).status_code == 200
|
||||
assert (await client.delete(url, headers=headers)).status_code == 200
|
||||
await process_due_messages()
|
||||
assert not await ScheduledMessage.filter(
|
||||
status=MessageStatus.PENDING, deleted_at__isnull=True
|
||||
).exists()
|
||||
|
||||
|
||||
async def test_api_assign_legacy_owner(job, api, providers, owner):
|
||||
job.created_by_id = None
|
||||
await job.save()
|
||||
client, headers = api
|
||||
response = await client.put(
|
||||
f"/api/cron/{job.id}", headers=headers, json={"created_by": str(owner.id)}
|
||||
)
|
||||
assert response.status_code == 200
|
||||
await job.refresh_from_db()
|
||||
assert (await execute_job(job, "run"))["ok"]
|
||||
|
||||
|
||||
async def test_due_snapshot_does_not_send_rescheduled_message(message, providers):
|
||||
await ScheduledMessage.filter(id=message.id).update(
|
||||
scheduled_at=delivery.now() + timedelta(hours=1), content="edited"
|
||||
)
|
||||
assert await process_message(message) == "skipped"
|
||||
providers[0].assert_not_awaited()
|
||||
|
||||
|
||||
async def test_claim_loads_current_content(message, providers):
|
||||
await ScheduledMessage.filter(id=message.id).update(content="edited")
|
||||
assert await process_message(message) == "sent"
|
||||
assert providers[0].await_args.args[2] == "edited"
|
||||
|
||||
|
||||
async def test_delete_recurring_hides_tombstone_and_prevents_repair(
|
||||
message, providers, api
|
||||
):
|
||||
message.recurrence = Recurrence.DAILY
|
||||
await message.save()
|
||||
await process_message(message)
|
||||
successor = await ScheduledMessage.get(previous_occurrence_id=message.id)
|
||||
client, headers = api
|
||||
response = await client.delete(
|
||||
f"/api/scheduled-messages/{successor.id}", headers=headers
|
||||
)
|
||||
assert response.status_code == 200
|
||||
rows = await (
|
||||
await client.get("/api/scheduled-messages/", headers=headers)
|
||||
).get_json()
|
||||
assert [r["id"] for r in rows] == [str(message.id)]
|
||||
await process_due_messages()
|
||||
assert await ScheduledMessage.all().count() == 2
|
||||
assert (await ScheduledMessage.get(id=successor.id)).deleted_at is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"status, safe", [(200, None), (401, True), (429, True), (500, False)]
|
||||
)
|
||||
async def test_email_provider_acknowledgement(monkeypatch, status, safe):
|
||||
import httpx
|
||||
|
||||
monkeypatch.setenv("MAILGUN_API_KEY", "test")
|
||||
monkeypatch.setenv("MAILGUN_DOMAIN", "test.invalid")
|
||||
transport = httpx.MockTransport(
|
||||
lambda request: httpx.Response(status, json={"id": "test"})
|
||||
)
|
||||
original = httpx.AsyncClient
|
||||
monkeypatch.setattr(httpx, "AsyncClient", lambda: original(transport=transport))
|
||||
if safe is None:
|
||||
await delivery.send(MessageChannel.EMAIL, "recipient", "body", "subject")
|
||||
else:
|
||||
with pytest.raises(
|
||||
delivery.SafeDeliveryError if safe else httpx.HTTPStatusError
|
||||
):
|
||||
await delivery.send(MessageChannel.EMAIL, "recipient", "body", "subject")
|
||||
|
||||
|
||||
async def test_missing_email_configuration_is_safe_failure(monkeypatch):
|
||||
monkeypatch.delenv("MAILGUN_API_KEY", raising=False)
|
||||
monkeypatch.delenv("MAILGUN_DOMAIN", raising=False)
|
||||
with pytest.raises(delivery.SafeDeliveryError):
|
||||
await delivery.send(MessageChannel.EMAIL, "recipient", "body")
|
||||
|
||||
|
||||
async def test_agent_adapter_uses_shared_runtime(owner, monkeypatch):
|
||||
import types
|
||||
|
||||
runtime = types.ModuleType("blueprints.conversation.runtime")
|
||||
invoke = AsyncMock(return_value={"messages": [{"content": "answer"}]})
|
||||
runtime.invoke_agent_for_user = invoke
|
||||
monkeypatch.setitem(sys.modules, "blueprints.conversation.runtime", runtime)
|
||||
assert (
|
||||
await delivery.agent_content(owner, "query", "thread", "scheduled") == "answer"
|
||||
)
|
||||
invoke.assert_awaited_once_with(owner, "query", "thread", channel="scheduled")
|
||||
|
||||
|
||||
async def test_migration_snapshot_preserves_prior_models_and_describes_new_models(db):
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from aerich.utils import compress_dict, decompress_dict, get_models_describe
|
||||
|
||||
def load(path):
|
||||
spec = importlib.util.spec_from_file_location("test_migration", path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
migration_dir = Path(__file__).resolve().parents[2] / "migrations" / "models"
|
||||
previous = load(migration_dir / "19_20261012225640_add_fk_indexes.py")
|
||||
migration = load(migration_dir / "20_20261012231017_application_foundation.py")
|
||||
state = decompress_dict(migration.MODELS_STATE)
|
||||
owned = {
|
||||
"models." + name
|
||||
for name in ("ScheduledMessage", "CronJob", "JobDelivery", "JobAttempt")
|
||||
}
|
||||
current = decompress_dict(compress_dict(get_models_describe("models")))
|
||||
for name in owned:
|
||||
assert state[name] == current[name]
|
||||
assert "models.User" in decompress_dict(previous.MODELS_STATE)
|
||||
sql = await migration.upgrade(None)
|
||||
assert 'UNIQUE ("kind", "job_id", "run_id")' in sql
|
||||
assert '"previous_occurrence_id" UUID UNIQUE' in sql
|
||||
assert '"created_by_id" UUID' in sql
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"content, expected",
|
||||
[
|
||||
([{"type": "text", "text": "answer"}], "answer"),
|
||||
("<think>private reasoning</think>answer", "answer"),
|
||||
],
|
||||
)
|
||||
async def test_agent_adapter_extracts_supported_content(
|
||||
owner, monkeypatch, content, expected
|
||||
):
|
||||
import types
|
||||
|
||||
runtime = types.ModuleType("blueprints.conversation.runtime")
|
||||
runtime.invoke_agent_for_user = AsyncMock(
|
||||
return_value={"messages": [{"content": content}]}
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "blueprints.conversation.runtime", runtime)
|
||||
assert await delivery.agent_content(owner, "query", "thread", "cron") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path, body",
|
||||
[
|
||||
("/api/scheduled-messages/", ["array"]),
|
||||
("/api/scheduled-messages/", {"recipient": 5, "channel": "email"}),
|
||||
("/api/cron/", ["array"]),
|
||||
("/api/cron/", {"name": 7, "prompt": "p", "recipient": "r"}),
|
||||
],
|
||||
)
|
||||
async def test_malformed_create_bodies_are_rejected(api, path, body):
|
||||
client, headers = api
|
||||
resp = await client.post(path, headers=headers, json=body)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_malformed_update_and_trigger_bodies(job, message, api, providers):
|
||||
client, headers = api
|
||||
for path in (f"/api/scheduled-messages/{message.id}", f"/api/cron/{job.id}"):
|
||||
resp = await client.put(path, headers=headers, json={"recipient": ["x"]})
|
||||
assert resp.status_code == 400
|
||||
resp = await client.post(
|
||||
f"/api/cron/{job.id}/trigger",
|
||||
headers={"X-Cron-Secret": "cron-secret"},
|
||||
json=["run"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
async def test_update_rejects_null_and_blank_required_fields(job, message, api):
|
||||
client, headers = api
|
||||
for path, field in (
|
||||
(f"/api/scheduled-messages/{message.id}", "recipient"),
|
||||
(f"/api/scheduled-messages/{message.id}", "content"),
|
||||
(f"/api/cron/{job.id}", "name"),
|
||||
(f"/api/cron/{job.id}", "prompt"),
|
||||
):
|
||||
for value in (None, " "):
|
||||
resp = await client.put(path, headers=headers, json={field: value})
|
||||
assert resp.status_code == 400, (path, field, value)
|
||||
# subject may be cleared, except where the channel requires it (email).
|
||||
resp = await client.put(
|
||||
f"/api/scheduled-messages/{message.id}", headers=headers, json={"subject": None}
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
resp = await client.put(
|
||||
f"/api/scheduled-messages/{message.id}",
|
||||
headers=headers,
|
||||
json={"subject": None, "channel": "imessage"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
async def test_cancel_started_one_off_message_is_rejected(message, providers, api):
|
||||
client, headers = api
|
||||
in_flight, release = asyncio.Event(), asyncio.Event()
|
||||
|
||||
async def slow_send(*args):
|
||||
in_flight.set()
|
||||
await release.wait()
|
||||
|
||||
providers[0].side_effect = slow_send
|
||||
task = asyncio.create_task(process_message(message))
|
||||
await in_flight.wait()
|
||||
response = await client.put(
|
||||
f"/api/scheduled-messages/{message.id}",
|
||||
headers=headers,
|
||||
json={"status": "cancelled"},
|
||||
)
|
||||
assert response.status_code == 409
|
||||
release.set()
|
||||
assert await task == "sent"
|
||||
assert await ScheduledMessage.all().count() == 1 # no tombstone for one-offs
|
||||
@@ -0,0 +1,164 @@
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from langchain_core.documents import Document
|
||||
from langchain_postgres import PGVector
|
||||
from quart import Quart
|
||||
from quart_jwt_extended import JWTManager, create_access_token
|
||||
from sqlalchemy import Column
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
|
||||
from blueprints.rag.retrieval import build_filter, citation_url, document_date
|
||||
from blueprints.rag import logic, source_api
|
||||
from blueprints.conversation.artifacts_util import search_documents_artifact
|
||||
|
||||
|
||||
@pytest.mark.parametrize("source", [None, "paperless", "obsidian"])
|
||||
@pytest.mark.parametrize(
|
||||
"start,end",
|
||||
[("2026-09-01", "2026-09-30"), ("2026-09-01", None), (None, "2026-09-30")],
|
||||
)
|
||||
def test_filters_compile_with_actual_pgvector_operator_rules(source, start, end):
|
||||
store = PGVector.__new__(PGVector)
|
||||
store.EmbeddingStore = SimpleNamespace(cmetadata=Column("cmetadata", JSONB))
|
||||
expression = store._create_filter_clause(build_filter(source, start, end))
|
||||
assert expression is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"start,end",
|
||||
[
|
||||
("20260901", None),
|
||||
("bad", None),
|
||||
("2026-10-01", "2026-09-01"),
|
||||
(None, "9999-12-31"),
|
||||
],
|
||||
)
|
||||
def test_date_filters_validate_before_vector_lookup(start, end):
|
||||
with pytest.raises(ValueError):
|
||||
build_filter("obsidian", start, end)
|
||||
|
||||
|
||||
async def test_filtered_query_preserves_citations_and_excludes_unknown_dates(
|
||||
monkeypatch,
|
||||
):
|
||||
identifier = str(uuid4())
|
||||
docs = [
|
||||
Document(
|
||||
id=identifier,
|
||||
page_content="September groceries plan",
|
||||
metadata={
|
||||
"source": "obsidian",
|
||||
"date": "2026-09-15",
|
||||
"filepath": "notes/budget.md",
|
||||
},
|
||||
),
|
||||
Document(
|
||||
id=str(uuid4()),
|
||||
page_content="Undated plan",
|
||||
metadata={"source": "obsidian"},
|
||||
),
|
||||
]
|
||||
store = SimpleNamespace(amax_marginal_relevance_search=AsyncMock(return_value=docs))
|
||||
monkeypatch.setattr(logic, "_get_vector_store", lambda: store)
|
||||
text, results = await logic.query_vector_store(
|
||||
"budget", source="obsidian", start_date="2026-09-01", end_date="2026-09-30"
|
||||
)
|
||||
assert results == docs[:1]
|
||||
assert f"Citation: /sources/{identifier}" in text
|
||||
assert store.amax_marginal_relevance_search.call_args.kwargs[
|
||||
"filter"
|
||||
] == build_filter("obsidian", "2026-09-01", "2026-09-30")
|
||||
|
||||
|
||||
def test_artifact_citations_identify_exact_retrieved_chunk(monkeypatch):
|
||||
monkeypatch.setenv("OBSIDIAN_VAULT_PATH", "/vault")
|
||||
identifier = str(uuid4())
|
||||
doc = Document(
|
||||
id=identifier,
|
||||
page_content="Savings target",
|
||||
metadata={
|
||||
"filepath": "/vault/notes/budget.md",
|
||||
"h1": "Budget",
|
||||
"h2": "Savings",
|
||||
},
|
||||
)
|
||||
artifact = search_documents_artifact("target", "obsidian", [doc])
|
||||
result = artifact["data"]["results"][0]
|
||||
assert result["url"] == f"/sources/{identifier}"
|
||||
assert result["source_id"] == identifier
|
||||
assert result["path"] == "notes/budget.md"
|
||||
assert result["headings"] == ["Budget", "Savings"]
|
||||
|
||||
|
||||
def test_document_date_and_unknown_citations():
|
||||
assert (
|
||||
document_date(
|
||||
{"created_date": datetime(2026, 9, 1, tzinfo=timezone.utc).timestamp()}
|
||||
)
|
||||
== "2026-09-01"
|
||||
)
|
||||
assert document_date({"date": "bad"}) is None
|
||||
assert citation_url(Document(page_content="no stable ID")) is None
|
||||
|
||||
|
||||
async def test_source_read_requires_auth_and_handles_missing_chunk(monkeypatch):
|
||||
app = Quart(__name__)
|
||||
app.config["JWT_SECRET_KEY"] = "test-secret"
|
||||
JWTManager(app)
|
||||
app.register_blueprint(source_api.sources_blueprint)
|
||||
calls = []
|
||||
|
||||
def load(identifier):
|
||||
calls.append(identifier)
|
||||
return {
|
||||
"id": identifier,
|
||||
"source": "paperless",
|
||||
"title": "Vet report",
|
||||
"content": "Retrieved section",
|
||||
"headings": [],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(source_api, "_load_chunk", load)
|
||||
identifier = str(uuid4())
|
||||
assert (
|
||||
await app.test_client().get(f"/api/sources/{identifier}")
|
||||
).status_code == 401
|
||||
async with app.app_context():
|
||||
token = create_access_token(identity=str(uuid4()))
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
response = await app.test_client().get(
|
||||
f"/api/sources/{identifier}", headers=headers
|
||||
)
|
||||
assert (await response.get_json())["content"] == "Retrieved section"
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
assert (
|
||||
await app.test_client().get("/api/sources/not-an-id", headers=headers)
|
||||
).status_code == 404
|
||||
assert calls == [identifier]
|
||||
|
||||
|
||||
async def test_post_filter_keeps_note_matched_by_any_date_field(monkeypatch):
|
||||
# The database filter matches if any date field is in range; the post-filter
|
||||
# must agree or matching notes silently vanish from results.
|
||||
doc = Document(
|
||||
id=str(uuid4()),
|
||||
page_content="Lease renewal notes",
|
||||
metadata={
|
||||
"source": "obsidian",
|
||||
"date": "2023-05-01",
|
||||
"created_at": "2024-06-12T09:00:00",
|
||||
"filepath": "notes/lease.md",
|
||||
},
|
||||
)
|
||||
store = SimpleNamespace(
|
||||
amax_marginal_relevance_search=AsyncMock(return_value=[doc])
|
||||
)
|
||||
monkeypatch.setattr(logic, "_get_vector_store", lambda: store)
|
||||
_, results = await logic.query_vector_store(
|
||||
"lease", source="obsidian", start_date="2024-06-01", end_date="2024-06-30"
|
||||
)
|
||||
assert results == [doc]
|
||||
@@ -26,6 +26,7 @@ async def owner():
|
||||
"models": [
|
||||
"blueprints.users.models",
|
||||
"blueprints.conversation.models",
|
||||
"blueprints.conversation.run_models",
|
||||
"blueprints.artifacts.models",
|
||||
]
|
||||
},
|
||||
@@ -91,12 +92,12 @@ def _final(text):
|
||||
async def _run(owner, monkeypatch, script, *, response_timeout=60, direct=False):
|
||||
user, conversation = owner
|
||||
monkeypatch.setattr(conv, "MODEL_STALL_TIMEOUT", LIMIT)
|
||||
monkeypatch.setattr(conv, "get_agent_for", lambda u: _TimedAgent(script))
|
||||
monkeypatch.setattr(conv.runtime, "get_agent_for", lambda u: _TimedAgent(script))
|
||||
|
||||
async def _prompt(user_id, query=""):
|
||||
return "system"
|
||||
|
||||
monkeypatch.setattr(conv, "build_system_prompt_with_memories", _prompt)
|
||||
monkeypatch.setattr(conv.runtime, "build_system_prompt_with_memories", _prompt)
|
||||
|
||||
app = Quart(__name__)
|
||||
app.config["RESPONSE_TIMEOUT"] = response_timeout
|
||||
@@ -300,18 +301,22 @@ async def test_no_response_is_counted_as_error(owner, monkeypatch):
|
||||
async def test_final_persistence_exit_has_exactly_one_outcome(
|
||||
owner, monkeypatch, caplog, cancelled
|
||||
):
|
||||
results, durations, writes = [], [], []
|
||||
original = conv.add_message_to_conversation
|
||||
# Answers are persisted by the detached run worker, so the request only
|
||||
# observes the terminal event; cancellation must not escape the worker.
|
||||
from blueprints.conversation import runs
|
||||
|
||||
async def persist(**kwargs):
|
||||
if kwargs["speaker"] == "simba":
|
||||
writes.append(kwargs)
|
||||
results, durations, writes = [], [], []
|
||||
original = runs.add_message_to_conversation
|
||||
|
||||
async def persist(conversation, message, speaker, *args, **kwargs):
|
||||
if speaker == "simba":
|
||||
writes.append(message)
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError()
|
||||
raise RuntimeError("persistence failed")
|
||||
return await original(**kwargs)
|
||||
return await original(conversation, message, speaker, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(conv, "add_message_to_conversation", persist)
|
||||
monkeypatch.setattr(runs, "add_message_to_conversation", persist)
|
||||
monkeypatch.setattr(
|
||||
conv, "count_result", lambda **kwargs: results.append(kwargs["result"])
|
||||
)
|
||||
@@ -325,13 +330,8 @@ async def test_final_persistence_exit_has_exactly_one_outcome(
|
||||
(0, _final("Answer")),
|
||||
]
|
||||
with caplog.at_level(logging.INFO):
|
||||
if cancelled:
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await _run(owner, monkeypatch, script, direct=True)
|
||||
else:
|
||||
frames = await _run(owner, monkeypatch, script, direct=True)
|
||||
assert frames[-1]["type"] == "error"
|
||||
assert "persistence failed" in frames[-1]["message"]
|
||||
assert len(writes) == 1
|
||||
assert results == ["cancelled" if cancelled else "error"]
|
||||
assert len(durations) == 1
|
||||
|
||||
Reference in New Issue
Block a user