diff --git a/CLAUDE.md b/CLAUDE.md
index f1fae3cd..84c8184f 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -56,7 +56,7 @@ how it gets built. Concretely:
## Architecture shape (don't fight it)
`routes/*` (HTTP only: validation, auth-dependency, serialization) → domain packages `sync/`, `plex/`,
-`downloads/`, `youtube/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
+`downloads/`, `youtube/`, `issues/` (shared by routes + scheduler + worker) → flat core (`auth`, `quota`,
`sysconfig`, `state`, `audit`, `notifications`, `userscope`, `realtime`, `ratelimit`, `progress`,
`security`, `logs`, `logsink`, `diagnostics`, `debugsession`) → `models/db/config`. **Dependency direction is one-way: core never imports routes.**
Frontend: `main.tsx` provider tree → `App.tsx` shell → `moduleRegistry` route-chunks → module
diff --git a/backend/alembic/versions/0090_debug_session_draft.py b/backend/alembic/versions/0090_debug_session_draft.py
new file mode 100644
index 00000000..6a021000
--- /dev/null
+++ b/backend/alembic/versions/0090_debug_session_draft.py
@@ -0,0 +1,27 @@
+"""add debug_sessions.draft — the issue being written from a session (E16 S3)
+
+Revision ID: 0090_debug_session_draft
+Revises: 0089_debug_sessions
+Create Date: 2026-09-19
+
+The composer saves as it goes: the title, the rendered markdown body that an export or an issue
+carries, the log rows the user took out, and the form's own fields so reopening it later (Settings →
+Diagnosztika lists past sessions) shows what was written. NULL until the composer first saves.
+"""
+
+import sqlalchemy as sa
+from alembic import op
+from sqlalchemy.dialects.postgresql import JSONB
+
+revision: str = "0090_debug_session_draft"
+down_revision: str | None = "0089_debug_sessions"
+branch_labels: str | None = None
+depends_on: str | None = None
+
+
+def upgrade() -> None:
+ op.add_column("debug_sessions", sa.Column("draft", JSONB, nullable=True))
+
+
+def downgrade() -> None:
+ op.drop_column("debug_sessions", "draft")
diff --git a/backend/app/README.md b/backend/app/README.md
index af8b21b3..9fb2cac1 100644
--- a/backend/app/README.md
+++ b/backend/app/README.md
@@ -13,11 +13,12 @@ Three layers, by *who* runs the code — not by feature:
response models, status-code mapping, serialization. It owns logic that runs *only* on a request.
Business logic that a request happens to trigger does **not** live here if anything else runs it too.
-2. **`plex/`, `downloads/`, `sync/` — domain logic shared by more than one caller.** Anything the
+2. **`plex/`, `downloads/`, `sync/`, `issues/` — domain logic shared by more than one caller.** Anything the
**scheduler or the worker** runs (not just a request) lives in one of these domain packages, so the
route, the worker, and the scheduler share one definition. Examples from this codebase:
`downloads/service.py` owns enqueue + the job state transitions (routes call it); `plex/sync.py` is
- run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline.
+ run by both the admin route and the scheduler; `downloads/pipeline.py` is the worker's child pipeline;
+ `issues/bundle.py` is the one definition of a bug report's files, for the zip export and the tracker.
3. **Flat `app/*.py` core modules — cross-cutting primitives.** `auth`, `sysconfig`, `quota`, `audit`,
`notifications`, `userscope`, `realtime`, `ratelimit`, `progress`, `logs` (the one logging
diff --git a/backend/app/debugsession.py b/backend/app/debugsession.py
index 98a18025..cb385ff8 100644
--- a/backend/app/debugsession.py
+++ b/backend/app/debugsession.py
@@ -127,6 +127,13 @@ def level_for(user: User, session: DebugSession | None) -> tuple[str, datetime |
return session.level, session.until
+def save_draft(session: DebugSession, draft: dict) -> None:
+ """Keep the composer's draft (the caller validated and commits). Shape: `title`, `body` (the
+ rendered markdown an export or an issue carries), `excluded` (log row ids taken out of the
+ evidence) and `form` (the composer's own fields, to reopen it as it was)."""
+ session.draft = draft
+
+
def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None:
if session is None:
return None
@@ -139,6 +146,7 @@ def serialize(session: DebugSession | None, screenshots: int = 0) -> dict | None
"ended_reason": session.ended_reason,
"issue_url": session.issue_url,
"screenshots": screenshots,
+ "draft": session.draft,
}
@@ -231,23 +239,24 @@ def _evidence(session: DebugSession):
return (LogEvent.debug_session_id == session.id, not_(upload_line.is_(True)))
-def bundle(db: Session, session: DebugSession) -> tuple[list[LogEvent], int]:
+def bundle(
+ db: Session, session: DebugSession, *, drop_excluded: bool = False
+) -> tuple[list[LogEvent], int]:
"""The session's log rows, browser and server interleaved in time, capped at BUNDLE_MAX — and
- how many rows it has in all, so a cut bundle can say so."""
+ how many rows it has in all, so a cut bundle can say so. `drop_excluded` leaves out the rows
+ the user took out in the composer: what an export or an issue carries."""
+ where = list(_evidence(session))
+ excluded = (session.draft or {}).get("excluded") or [] if drop_excluded else []
+ if excluded:
+ where.append(LogEvent.id.not_in(excluded))
rows = list(
db.scalars(
- select(LogEvent)
- .where(*_evidence(session))
- .order_by(LogEvent.ts, LogEvent.id)
- .limit(BUNDLE_MAX)
+ select(LogEvent).where(*where).order_by(LogEvent.ts, LogEvent.id).limit(BUNDLE_MAX)
)
)
total = len(rows)
if total == BUNDLE_MAX:
- total = (
- db.scalar(select(func.count()).select_from(LogEvent).where(*_evidence(session)))
- or total
- )
+ total = db.scalar(select(func.count()).select_from(LogEvent).where(*where)) or total
return rows, total
diff --git a/backend/app/issues/__init__.py b/backend/app/issues/__init__.py
new file mode 100644
index 00000000..dd2ed760
--- /dev/null
+++ b/backend/app/issues/__init__.py
@@ -0,0 +1,3 @@
+"""Bug reports built from debug sessions (E16): the files an issue carries, and where they go —
+downloaded as a zip, or (S4) sent to a Gitea issue tracker. The session itself — what is recorded
+and tagged — is the core module `app/debugsession.py`."""
diff --git a/backend/app/issues/bundle.py b/backend/app/issues/bundle.py
new file mode 100644
index 00000000..07d02a7d
--- /dev/null
+++ b/backend/app/issues/bundle.py
@@ -0,0 +1,77 @@
+"""What an issue from a debug session carries, as files — one definition for the zip export and the
+Gitea upload, so the two cannot drift apart.
+
+ * `issue.md` — the title and the body the user wrote and reviewed in the composer (rendered there,
+ in their language; the server does not rewrite it);
+ * `client.log` / `server.log` — the session's evidence (`debugsession.bundle`) minus the rows the
+ user took out, one JSON object per line, browser rows and server rows apart. `.log`, not
+ `.jsonl`: Gitea refuses that extension (measured, plans/e16 S0);
+ * the screenshots, in the order they were taken.
+
+Every row is already redacted twice (at capture, and again when stored), and nothing here adds a
+field the review list did not show.
+"""
+
+import io
+import json
+import zipfile
+from dataclasses import dataclass
+
+from sqlalchemy import select
+from sqlalchemy.orm import Session
+
+from app import debugsession, logsink
+from app.models import DebugScreenshot, DebugSession
+
+_EXT = {"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp"}
+
+
+@dataclass(frozen=True)
+class IssueFile:
+ name: str
+ data: bytes
+ mime: str
+
+
+def title_of(session: DebugSession) -> str:
+ # Not a sentence of ours: the composer always saves a title in the user's language, and a
+ # session exported before it ever saved is named by its date alone, in no language at all.
+ title = ((session.draft or {}).get("title") or "").strip()
+ return title or f"{session.started_at:%Y-%m-%d %H:%M} · {session.id}"
+
+
+def body_of(session: DebugSession) -> str:
+ return ((session.draft or {}).get("body") or "").strip()
+
+
+def _lines(rows) -> bytes:
+ return "".join(
+ json.dumps(logsink.serialize(r), ensure_ascii=False, default=str) + "\n" for r in rows
+ ).encode()
+
+
+def files(db: Session, session: DebugSession) -> list[IssueFile]:
+ """The session's issue as files: the text, the two logs, the screenshots."""
+ rows, _ = debugsession.bundle(db, session, drop_excluded=True)
+ text = f"# {title_of(session)}\n\n{body_of(session)}\n"
+ out = [
+ IssueFile("issue.md", text.encode(), "text/markdown"),
+ IssueFile("client.log", _lines(r for r in rows if r.source == "web"), "text/plain"),
+ IssueFile("server.log", _lines(r for r in rows if r.source != "web"), "text/plain"),
+ ]
+ shots = db.scalars(
+ select(DebugScreenshot)
+ .where(DebugScreenshot.session_id == session.id)
+ .order_by(DebugScreenshot.id)
+ )
+ for n, shot in enumerate(shots, start=1):
+ out.append(IssueFile(f"screenshot-{n}.{_EXT.get(shot.mime, 'bin')}", shot.data, shot.mime))
+ return out
+
+
+def zipped(items: list[IssueFile]) -> bytes:
+ buf = io.BytesIO()
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
+ for item in items:
+ z.writestr(item.name, item.data)
+ return buf.getvalue()
diff --git a/backend/app/models.py b/backend/app/models.py
index a827d2d2..3700f58a 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -619,6 +619,8 @@ class DebugSession(Base):
# app/debugsession.py `EndReason`: stopped by the user, or past `until`.
ended_reason: Mapped[str | None] = mapped_column(String(16))
issue_url: Mapped[str | None] = mapped_column(String(512))
+ # The issue being written from it (E16 S3; app/debugsession.py `save_draft` holds its shape).
+ draft: Mapped[dict | None] = mapped_column(JSONB)
class DebugScreenshot(Base):
diff --git a/backend/app/routes/debug_sessions.py b/backend/app/routes/debug_sessions.py
index a3722d74..06f8834a 100644
--- a/backend/app/routes/debug_sessions.py
+++ b/backend/app/routes/debug_sessions.py
@@ -10,10 +10,12 @@ form — one image per request, capped while it arrives, and kept only if its by
those types, whatever the Content-Type claimed.
"""
+import json
from datetime import datetime, timezone
+from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Request, Response
-from pydantic import BaseModel
+from pydantic import BaseModel, Field
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -21,6 +23,7 @@ from sqlalchemy.orm import Session
from app import debugsession, logs, logsink
from app.auth import require_human
from app.db import get_db
+from app.issues import bundle
from app.models import DebugScreenshot, DebugSession, User
from app.routes._common import capped_body, not_found, owned_or_404
from app.utils import iso
@@ -111,6 +114,59 @@ def session_events(
return {"items": [logsink.serialize(r) for r in rows], "total": total}
+# --- the draft and its export ---------------------------------------------------------------
+# The composer's own fields (steps, description…) are its business, kept whole so it reopens as it
+# was — bounded here so a draft cannot become a place to store anything else.
+FORM_MAX_BYTES = 64 * 1024
+
+
+class DraftIn(BaseModel):
+ title: str = Field(max_length=200)
+ # The rendered markdown the export and the issue carry.
+ body: str = Field(max_length=50_000)
+ # Log rows taken out of the evidence in the review.
+ excluded: list[int] = Field(default_factory=list, max_length=debugsession.BUNDLE_MAX)
+ form: dict[str, Any] = Field(default_factory=dict)
+
+
+@router.put("/{sid}/draft")
+def save_draft(
+ sid: str,
+ body: DraftIn,
+ user: User = Depends(require_human),
+ db: Session = Depends(get_db),
+) -> dict:
+ session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
+ if session.issue_url is not None:
+ # Sent: the issue is on the tracker now, and a draft edited after it would say otherwise.
+ raise HTTPException(status_code=409, detail={"code": "already_sent"})
+ if len(json.dumps(body.form, ensure_ascii=False).encode()) > FORM_MAX_BYTES:
+ raise HTTPException(status_code=413, detail={"code": "too_large"})
+ debugsession.save_draft(session, body.model_dump())
+ db.commit()
+ return {"ok": True, "session": _view(db, session)}
+
+
+@router.get("/{sid}/export")
+def export_session(
+ sid: str, user: User = Depends(require_human), db: Session = Depends(get_db)
+) -> Response:
+ """The issue as a zip — the text, the two logs and the screenshots — for an instance without an
+ issue tracker, or for sending it by hand. Built from the SAVED draft: what the composer shows is
+ what it saved."""
+ session = owned_or_404(db, DebugSession, sid, user, what="debug_session")
+ data = bundle.zipped(bundle.files(db, session))
+ name = f"siftlode-debug-{session.started_at:%Y%m%d-%H%M}-{session.id[:6]}.zip"
+ return Response(
+ content=data,
+ media_type="application/zip",
+ headers={
+ "Content-Disposition": f'attachment; filename="{name}"',
+ "Cache-Control": "no-store",
+ },
+ )
+
+
# --- screenshots ---------------------------------------------------------------------------
def _shot_view(shot: DebugScreenshot) -> dict:
return {"id": shot.id, "mime": shot.mime, "size": shot.size, "created_at": iso(shot.created_at)}
diff --git a/backend/tests/test_debug_sessions.py b/backend/tests/test_debug_sessions.py
index f279d489..2ca2b60a 100644
--- a/backend/tests/test_debug_sessions.py
+++ b/backend/tests/test_debug_sessions.py
@@ -127,10 +127,13 @@ def test_nobody_reaches_another_users_session(db, make_user, as_user):
("get", f"/api/debug-sessions/{sid}/screenshots"),
("get", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
("delete", f"/api/debug-sessions/{sid}/screenshots/{shot}"),
+ ("get", f"/api/debug-sessions/{sid}/export"),
):
r = getattr(stranger, method)(path)
assert r.status_code == 404, (method, path, r.status_code)
assert r.json()["detail"] == {"code": "not_found", "reason": "debug_session"}
+ r = stranger.put(f"/api/debug-sessions/{sid}/draft", json={"title": "x", "body": "y"})
+ assert r.status_code == 404
r = stranger.post(
f"/api/debug-sessions/{sid}/screenshots",
content=_PNG,
@@ -446,3 +449,97 @@ def test_a_screenshot_can_be_removed_and_is_then_gone(recording):
def test_the_upload_path_left_out_of_the_evidence_is_the_real_one():
assert debugsession.INGEST_PATH in {r.path for r in diagnostics_routes.router.routes}
+
+
+# --- the draft and the export ---------------------------------------------------------------
+
+
+def test_a_draft_is_kept_and_comes_back_with_the_session(recording):
+ client, sid = recording
+ draft = {
+ "title": "Spinner stays",
+ "body": "## Steps\n1. open",
+ "excluded": [3],
+ "form": {"a": 1},
+ }
+ r = client.put(f"/api/debug-sessions/{sid}/draft", json=draft)
+ assert r.status_code == 200 and r.json()["session"]["draft"] == draft
+ assert client.get(f"/api/debug-sessions/{sid}").json()["draft"] == draft
+
+
+def test_a_draft_is_bounded(recording, monkeypatch):
+ from app.routes import debug_sessions as routes
+
+ client, sid = recording
+ monkeypatch.setattr(routes, "FORM_MAX_BYTES", 16)
+ r = client.put(
+ f"/api/debug-sessions/{sid}/draft",
+ json={"title": "t", "body": "b", "form": {"text": "x" * 32}},
+ )
+ assert r.status_code == 413
+ too_long = {"title": "t" * 201, "body": "b"}
+ assert client.put(f"/api/debug-sessions/{sid}/draft", json=too_long).status_code == 422
+
+
+def test_a_sent_session_keeps_its_draft(db, recording):
+ client, sid = recording
+ db.get(DebugSession, sid).issue_url = "https://git.example/o/r/issues/1"
+ db.commit()
+ r = client.put(f"/api/debug-sessions/{sid}/draft", json={"title": "t", "body": "b"})
+ assert r.status_code == 409 and r.json()["detail"]["code"] == "already_sent"
+
+
+def test_the_export_is_the_saved_draft_the_evidence_and_the_screenshots(db, make_user, as_user):
+ import io
+ import json
+ import zipfile
+
+ user = make_user()
+ client = as_user(user)
+ sid = _start(client)["id"]
+ now = datetime.now(timezone.utc)
+
+ def row(ts, source, msg):
+ return LogEvent(
+ ts=ts,
+ level="INFO",
+ sev=9,
+ source=source,
+ logger=source,
+ event="x.y",
+ msg=msg,
+ user_id=user.id,
+ debug_session_id=sid,
+ )
+
+ kept_web, dropped_web, kept_api = (
+ row(now, "web", "kept click"),
+ row(now + timedelta(seconds=1), "web", "private search"),
+ row(now + timedelta(seconds=2), "api", "GET /api/x 200"),
+ )
+ db.add_all([kept_web, dropped_web, kept_api])
+ db.commit()
+ _upload(client, sid, _PNG)
+ _upload(client, sid, _JPEG)
+ client.put(
+ f"/api/debug-sessions/{sid}/draft",
+ json={"title": "Spinner stays", "body": "It spins.", "excluded": [dropped_web.id, 999999]},
+ )
+
+ r = client.get(f"/api/debug-sessions/{sid}/export")
+ assert r.status_code == 200 and r.headers["content-type"] == "application/zip"
+ assert "attachment;" in r.headers["content-disposition"]
+ z = zipfile.ZipFile(io.BytesIO(r.content))
+ assert z.namelist() == [
+ "issue.md",
+ "client.log",
+ "server.log",
+ "screenshot-1.png",
+ "screenshot-2.jpg",
+ ]
+ assert z.read("issue.md").decode() == "# Spinner stays\n\nIt spins.\n"
+ web = [json.loads(line)["msg"] for line in z.read("client.log").decode().splitlines()]
+ api = [json.loads(line)["msg"] for line in z.read("server.log").decode().splitlines()]
+ assert "kept click" in web and "private search" not in web
+ assert "GET /api/x 200" in api and all(m != "kept click" for m in api)
+ assert z.read("screenshot-1.png") == _PNG
diff --git a/frontend/src/components/DebugRecorder.dom.test.tsx b/frontend/src/components/DebugRecorder.dom.test.tsx
index 0a6eb327..b6130bca 100644
--- a/frontend/src/components/DebugRecorder.dom.test.tsx
+++ b/frontend/src/components/DebugRecorder.dom.test.tsx
@@ -35,6 +35,7 @@ const { order, session, startDebugSession, stopDebugSession } = vi.hoisted(() =>
ended_reason: null,
issue_url: null,
screenshots: 0,
+ draft: null,
...over,
});
const startDebugSession = vi.fn((level: "DEBUG" | "TRACE") =>
diff --git a/frontend/src/components/DebugRecorder.tsx b/frontend/src/components/DebugRecorder.tsx
index a72f2b8d..31073588 100644
--- a/frontend/src/components/DebugRecorder.tsx
+++ b/frontend/src/components/DebugRecorder.tsx
@@ -1,14 +1,15 @@
import { useEffect, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
import { useTranslation } from "react-i18next";
-import { useQueryClient } from "@tanstack/react-query";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
-import type { DebugSessionLevel } from "../lib/api";
+import { api, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import {
closeRecorderStart,
openComposer,
openRecorderStart,
+ useComposerSession,
useRecorderStartOpen,
useStepCount,
} from "../lib/debugRecorder";
@@ -19,8 +20,10 @@ import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
+import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { inputCls } from "./ui/form";
+import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
// The debug recorder (E16): Start → reproduce → Stop.
@@ -47,6 +50,7 @@ export default function DebugRecorder() {
const me = useMe();
const { session } = useDebugRecording();
const startOpen = useRecorderStartOpen();
+ const composing = useComposerSession();
const id = session?.id;
useEffect(() => (id ? dockUnderToasts(RecorderPill) : undefined), [id]);
@@ -66,7 +70,12 @@ export default function DebugRecorder() {
return () => window.clearTimeout(timer);
}, [id, until, minutes, qc, t]);
- return startOpen ? : null;
+ return (
+ <>
+ {startOpen && }
+ {composing && }
+ >
+ );
}
function StartDialog() {
@@ -216,8 +225,17 @@ function Elapsed({ since }: { since: string }) {
);
}
-/** Settings → Diagnosztika: start a recording, or see the one running and stop it. */
+/** Settings → Diagnosztika: start a recording or stop the one running, and reopen a past one. */
export function RecorderSettings() {
+ return (
+
+
+
+
+ );
+}
+
+function RecorderControl() {
const { t } = useTranslation();
const { session, stop } = useDebugRecording();
if (!session)
@@ -294,3 +312,53 @@ function StepPrompt({ id, onDone }: { id: string; onDone: (label: string | null)
);
}
+
+/** The last sessions, newest first — reopening one opens its composer, with what was written. */
+function SessionHistory() {
+ const { t, i18n } = useTranslation();
+ const q = useQuery({ queryKey: qk.debugSessions(), queryFn: api.debugSessions });
+ const items = q.data?.items.filter((s) => s.ended_at) ?? [];
+ if (q.isError && !q.data)
+ return (
+ void q.refetch()}>
+ {t("recorder.history.failed")}
+
+ );
+ if (!q.data) return ;
+ return (
+