Add durable chat runs, shared agent runtime, reliable jobs, history tools and citations
CI / backend (pull_request) Successful in 42s
CI / frontend (pull_request) Failing after 7s
CI / backend (push) Successful in 41s
CI / frontend (push) Failing after 7s

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:
2026-10-12 23:08:06 -04:00
parent de88679f25
commit 5809ad3adb
68 changed files with 7751 additions and 1981 deletions
+6
View File
@@ -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)
+28 -1
View File
@@ -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))
+58 -113
View File
@@ -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>")
+31 -8
View File
@@ -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 ""
+277
View File
@@ -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))
+1
View File
@@ -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"
+2
View File
@@ -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)
+42
View File
@@ -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"),)
+576
View File
@@ -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.",
},
)
+171
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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()
+59
View File
@@ -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}
+11 -15
View File
@@ -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", [])
)
+3 -3
View File
@@ -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:
+8 -20
View File
@@ -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(
+116
View File
@@ -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
View File
@@ -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
+105
View File
@@ -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
+86
View File
@@ -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
+206 -16
View File
@@ -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
+171
View File
@@ -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
+42
View File
@@ -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"
+156 -66
View File
@@ -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
+6 -12
View File
@@ -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(
+13
View File
@@ -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
+26 -2
View File
@@ -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}"
+1
View File
@@ -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",
+3
View File
@@ -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:-}
+54
View File
@@ -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.
+103
View File
@@ -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
View File
@@ -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="
)
+2
View File
@@ -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
+2
View File
@@ -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>`. */
+192 -19
View File
@@ -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();
}
}
}
+56
View File
@@ -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();
}
}
+47 -14
View File
@@ -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
View File
@@ -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,
+25 -3
View File
@@ -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, /&lt;script&gt;cats&lt;\/script&gt;/);
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</);
});
+166
View File
@@ -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, /&lt;script&gt;rejected&lt;\/script&gt;/);
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;
}
});
+76
View File
@@ -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
View File
@@ -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
+137
View File
@@ -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"]
+3 -2
View File
@@ -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"
+482
View File
@@ -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"
+7 -2
View File
@@ -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):
+260
View File
@@ -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"]
)
+111
View File
@@ -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"
+690
View File
@@ -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
+164
View File
@@ -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]
+14 -14
View File
@@ -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