Single consolidated commit for four completed, validated phases (77, 78, 79, 80). The pipeline run left all work uncommitted because the harness commits only with PHASE_COMMIT=1 while child executors are forbidden from committing; the phases themselves all passed validation and moved to .agents/phases/complete/. Phase 77 — navbar view refresh - router.js dispatches bor:view-refresh on re-show / active re-click / popstate (gated on wasMounted; first show and boot exempt) - History / RAG / Sources / Tuning re-fetch on refresh (admin branch); Chat deliberately excluded (stream survival) - History "Refresh" button (admin-only, in-flight disable + status line) - New story suite tests/e2e/test_navbar_refresh.py (7 tests) Phase 78 — static background - Removed the animated glow layers; static 44px grid over the flat --bg canvas; default and reduced-motion renders byte-identical - Updated background/theme E2E suites; removed bg-glow test pins Phase 79 — API tokens - api_tokens model + migration 0012; hash-only token service - Admin tokens API + Tokens admin view; POST /api/token-auth; live-revoking require_user on chat / suggestions / document content - Frontend token gate with localStorage cache; anonymous E2E suites migrated to token login - New story suite tests/e2e/test_api_tokens.py (9 tests) Phase 80 — history suggestion chips - last_questions() endpoint with SEED fallback; startNewChat() refetch - Seed-semantics docs (config.py, .env.example, README) - Integration state matrix + E2E suite rewritten to the 4 chip states Also included: phase-76 report artifacts and the repo restore-test-db skill (previously untracked), scripts/* ruff fixes from phase 77. Final gate state (phase 80 final pass, covers everything above): - uv run pytest --cov=app → 1637 passed, 0 failed, app/ coverage 99% - uv run ruff check . && uv run pyright → clean, 0 errors - Per-phase story E2E suites green in isolation
768 lines
27 KiB
Python
768 lines
27 KiB
Python
"""Pydantic request/response schemas (API contract)."""
|
|
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any, Literal
|
|
|
|
from pydantic import (
|
|
BaseModel,
|
|
ConfigDict,
|
|
Field,
|
|
SerializerFunctionWrapHandler,
|
|
field_validator,
|
|
model_serializer,
|
|
)
|
|
|
|
|
|
class HealthResponse(BaseModel):
|
|
status: str
|
|
db: str
|
|
version: str
|
|
environment: str
|
|
|
|
|
|
class SuggestionList(BaseModel):
|
|
suggestions: list[str]
|
|
|
|
|
|
class HistoryTurn(BaseModel):
|
|
"""One prior chat turn the client sends with ``POST /api/chat``
|
|
(phase 74, TODO L4).
|
|
|
|
The endpoint stays stateless (A10): the client's ``bor.chat.v1``
|
|
conversation record (minus the question about to be asked) is
|
|
provided in the request body as ``history`` so a follow-up question
|
|
reaches the model together with the exchange so far — and, for
|
|
preserve-thinking models, with the prior brain turns' thinking (the
|
|
record has carried the ``thinking`` key since phase 17).
|
|
|
|
``thinking`` travels to the model as ``reasoning_content`` on the
|
|
assistant message (the wire convention :mod:`app.rag.llm` already
|
|
documents for the response side) — only when non-empty (A4).
|
|
``text`` mirrors :attr:`ChatMessage.text`'s answer shape; long
|
|
answers (and the scratchpads that ride along as ``thinking``) both
|
|
run to tens of kilobytes of text, so both share the same loose
|
|
boundary cap. These are boundary sanity caps only — the real
|
|
trimming budget is the settings pair ``history_max_turns`` /
|
|
``history_max_chars`` (``app.config``, A3: a capped-out turn is
|
|
dropped whole, never truncated). The old 4000-char text cap was
|
|
stricter than the 24_000-char default total budget and rejected
|
|
any second turn in a chat whose history held a long answer (422 —
|
|
found by the phase-42 E2E suite on the phase-76 shell).
|
|
"""
|
|
|
|
who: Literal["user", "brain"]
|
|
text: str = Field(min_length=1, max_length=32_000)
|
|
thinking: str | None = Field(default=None, max_length=32000)
|
|
|
|
|
|
class ChatRequest(BaseModel):
|
|
"""``POST /api/chat`` body: the current question plus the optional
|
|
prior turns (phase 74 — the client-provided history, stateless per
|
|
A10).
|
|
|
|
``history`` is the client's earlier turns, oldest first (the
|
|
``bor.chat.v1`` record minus the current question); the mapper
|
|
(:func:`app.rag.prompts.history_to_messages`) trims it newest-first
|
|
against the settings budgets and maps it to model messages. The
|
|
schema-level ``max_length=100`` is a DoS sanity ceiling only — the
|
|
config budgets do the real trimming (A3). Absent or empty keeps the
|
|
request byte-identical to pre-phase-74: the model sees exactly the
|
|
two-message ``[system, user]`` request.
|
|
"""
|
|
|
|
message: str = Field(min_length=1, max_length=4000)
|
|
history: list[HistoryTurn] = Field(default_factory=list, max_length=100)
|
|
|
|
class LoginRequest(BaseModel):
|
|
"""``POST /api/login`` body (phase 16): the single admin's password.
|
|
|
|
An empty or wrong password is a 401 with one generic detail — never a
|
|
422 that would hint at input-shape differences.
|
|
"""
|
|
|
|
password: str = ""
|
|
|
|
|
|
class WhoamiResponse(BaseModel):
|
|
"""``GET /api/whoami`` (phase 16; phase 79 added the ``user`` role)
|
|
— drives all UI gating. ``authenticated`` is true for admin AND
|
|
user; the UI's admin-only surfaces key off ``role === "admin"``.
|
|
"""
|
|
|
|
authenticated: bool
|
|
role: str # "admin" | "user" | "anonymous"
|
|
|
|
|
|
class SourceRef(BaseModel):
|
|
source: str
|
|
path: str
|
|
title: str
|
|
|
|
|
|
class ChatThinkingEvent(BaseModel):
|
|
"""SSE thinking event: one chunk of the model's reasoning (phase 17).
|
|
|
|
PLAN §4 extension (A15, owner permission 2026-08-23): frames of the
|
|
shape ``{type: "thinking", text: str}`` stream ahead of the
|
|
``delta`` frames in practice (the model reasons before it answers). The
|
|
client renders them in a collapsible "Thinking" block; the ``done``
|
|
event shape is unchanged and thinking text never travels on it.
|
|
Sibling of :class:`ChatErrorEvent`.
|
|
"""
|
|
|
|
type: str = "thinking"
|
|
text: str
|
|
|
|
|
|
class ChatToolEvent(BaseModel):
|
|
"""SSE frame for one agent tool call (phase 37, PLAN §4 extension).
|
|
|
|
A15 extension (owner permission 2026-08-26; the grep added in phase
|
|
68; phase 70 aligned the surface to the harness-trained
|
|
``ls`` / ``read`` / ``grep`` — owner permission 2026-09-03): a
|
|
grounded turn may call the server-side document tools (``ls`` /
|
|
``read`` / ``grep``, see :mod:`app.rag.agent`); each model-requested
|
|
call streams as ``{type: "tool", name: str, argument: str | null}``
|
|
ahead of the answer's ``delta`` frames. ``argument`` is the single
|
|
string argument the model passed — ``read``'s ``path`` (the combined
|
|
``source/path``), ``grep``'s ``pattern``, ``ls``'s ``path`` — or
|
|
null (a non-string value, a model error the backend refuses, and an
|
|
omitted argument both yield null). The client renders each frame as
|
|
a "calling tool" line/state (phase 37 task 05); the ``delta`` /
|
|
``done`` shapes are unchanged — the read document is reflected in
|
|
``done.sources`` instead (a grep adds no source: it is a locator,
|
|
locked A5).
|
|
"""
|
|
|
|
type: Literal["tool"] = "tool"
|
|
name: str # "ls" | "read" | "grep" (whatever AGENT_TOOLS names)
|
|
argument: str | None = None # the single string argument passed, or null
|
|
|
|
|
|
class ChatDoneEvent(BaseModel):
|
|
"""Final SSE event of a chat turn: metadata for the finished answer."""
|
|
|
|
type: str = "done"
|
|
deflected: bool
|
|
sources: list[SourceRef]
|
|
suggestions: list[str] = []
|
|
|
|
|
|
class ChatErrorEvent(BaseModel):
|
|
"""SSE error event: a turn that cannot complete (PLAN §4).
|
|
|
|
The client's loading-feedback state machine (phase 06) keys off this
|
|
exact shape — ``{type: "error", detail: str}`` — to flip to the error
|
|
state and re-enable the send button.
|
|
"""
|
|
|
|
type: str = "error"
|
|
detail: str
|
|
|
|
|
|
class ChatRetryEvent(BaseModel):
|
|
"""SSE retry event: an LLM request is restarted before the first token
|
|
(phase 67, owner-locked 2026-09-01).
|
|
|
|
Sibling of :class:`ChatErrorEvent`, but transient — the client shows a
|
|
live status on the existing ``#send-status`` line (locked A4:
|
|
"Communication interrupted — retrying (n of N)…") and the send button
|
|
stays the Stop control; it never flips the state machine to error. It
|
|
is only ever sent when the failed attempt had NOT streamed a single
|
|
output frame yet (locked A2: no thinking/tool/delta emitted) — once
|
|
tokens are flowing, a failure is terminal (the ``error`` frame) and
|
|
this event cannot appear.
|
|
|
|
``attempt`` is the 1-based number of the attempt the endpoint is about
|
|
to try next (what the endpoint sends — the first failure of a
|
|
4-attempt budget carries ``attempt=2``); ``max_attempts`` is the total
|
|
attempt budget (``llm_retries + 1``).
|
|
"""
|
|
|
|
type: Literal["retry"] = "retry"
|
|
attempt: int
|
|
max_attempts: int
|
|
|
|
|
|
class DocSummary(BaseModel):
|
|
"""One indexed document as shown on the Sources page / API."""
|
|
|
|
id: str
|
|
source: str
|
|
path: str
|
|
title: str
|
|
chunks: int
|
|
indexed_at: str
|
|
|
|
|
|
class DocList(BaseModel):
|
|
"""Response of ``GET /api/docs`` (empty list → designed empty state)."""
|
|
|
|
documents: list[DocSummary]
|
|
|
|
|
|
class DocContent(BaseModel):
|
|
"""One indexed document's full content (feeds the viewer page, phase 10)."""
|
|
|
|
source: str
|
|
path: str
|
|
title: str
|
|
format: str
|
|
#: Lite-model summary (phase 30) — non-markdown A9 docs only; None for
|
|
#: markdown documents, pre-phase-30 rows, and the fail-soft path where
|
|
#: summary generation failed but the document was still indexed.
|
|
summary: str | None = None
|
|
content: str
|
|
indexed_at: str
|
|
chunks: int
|
|
|
|
|
|
class SummaryUpdate(BaseModel):
|
|
"""``PATCH /api/documents/summary`` body (phase 57, task 01).
|
|
|
|
``source`` / ``path`` name the indexed document (the same pair the
|
|
public ``GET /api/documents/content`` looks up); ``summary`` is the
|
|
raw new text. The API strips it before storing — an
|
|
empty/whitespace-only value is the *clear* operation (a first-class
|
|
action, phase 57 D4), not a 422. Unconstrained on purpose: unknown
|
|
pairs must 404 as "document not found" (row-lookup semantics),
|
|
exactly like the public content endpoint.
|
|
"""
|
|
|
|
source: str
|
|
path: str
|
|
summary: str
|
|
|
|
|
|
class SummaryResult(BaseModel):
|
|
"""``PATCH /api/documents/summary`` response (phase 57, task 01).
|
|
|
|
``summary`` is the stored text after the change (``null`` after a
|
|
clear — the viewer's summary box hides on null) and ``chunks`` the
|
|
document's post-change total chunk count: an update leaves the
|
|
content chunks untouched (the count is unchanged — only the single
|
|
``is_summary`` chunk is replaced), a clear drops one (the
|
|
``is_summary`` chunk is deleted).
|
|
"""
|
|
|
|
source: str
|
|
path: str
|
|
summary: str | None
|
|
chunks: int
|
|
|
|
|
|
class SteeringNoteIn(BaseModel):
|
|
"""``POST /api/steering`` body: one tuning instruction (phase 15).
|
|
|
|
The note is trimmed *before* the length constraints run, so a
|
|
whitespace-only body is a 422 and a 2000-char note with surrounding
|
|
spaces still passes.
|
|
"""
|
|
|
|
note: str = Field(min_length=1, max_length=2000)
|
|
|
|
@field_validator("note", mode="before")
|
|
@classmethod
|
|
def _trim_note(cls, v: object) -> object:
|
|
return v.strip() if isinstance(v, str) else v
|
|
|
|
|
|
class SteeringNoteUpdate(BaseModel):
|
|
"""``PUT /api/steering/{id}`` body: a new tuning instruction (phase 27).
|
|
|
|
Mirrors :class:`SteeringNoteIn` — the note is trimmed *before* the
|
|
length constraints run, so an empty/whitespace body is a 422 and a
|
|
full replacement that is ≤2000 chars after the trim still passes.
|
|
"""
|
|
|
|
note: str = Field(min_length=1, max_length=2000)
|
|
|
|
@field_validator("note", mode="before")
|
|
@classmethod
|
|
def _trim_note(cls, v: object) -> object:
|
|
return v.strip() if isinstance(v, str) else v
|
|
|
|
|
|
class SteeringNote(BaseModel):
|
|
"""One stored steering note (API shape — ISO-8601 ``created_at``)."""
|
|
|
|
id: uuid.UUID
|
|
note: str
|
|
created_at: datetime
|
|
|
|
|
|
class SteeringNoteList(BaseModel):
|
|
"""``GET /api/steering`` response: all notes, newest first."""
|
|
|
|
notes: list[SteeringNote]
|
|
|
|
|
|
class GitSourceIn(BaseModel):
|
|
"""``POST /api/git-sources`` body (phase 35, task 02; ``kind``, phase 38).
|
|
|
|
``kind`` selects the source kind and which field carries its location:
|
|
|
|
* ``"git"`` (default) — ``url`` is the repo URL. Mirrors the
|
|
phase-35 contract: trimmed *before* the length constraints run, so a
|
|
whitespace-only body is a 422 and a URL with surrounding spaces is
|
|
stored clean. Shape validation (``https://``, ``ssh://``, ``git@``)
|
|
and the kind-field rules (url present, no path) happen in the API
|
|
layer so the 422/409 details stay fixed strings that never echo the
|
|
input (credential safety).
|
|
* ``"local"`` — ``path`` is an existing directory on the server.
|
|
Trimmed here; the API layer then ``expanduser()``s it and requires an
|
|
absolute existing directory (else 422 naming the path — the path is
|
|
not a secret, unlike a git URL) and no ``url``.
|
|
"""
|
|
|
|
kind: Literal["git", "local"] = "git"
|
|
url: str | None = Field(default=None, min_length=1, max_length=500)
|
|
path: str | None = Field(default=None, min_length=1, max_length=2000)
|
|
|
|
@field_validator("url", mode="before")
|
|
@classmethod
|
|
def _trim_url(cls, v: object) -> object:
|
|
return v.strip() if isinstance(v, str) else v
|
|
|
|
@field_validator("path", mode="before")
|
|
@classmethod
|
|
def _trim_path(cls, v: object) -> object:
|
|
return v.strip() if isinstance(v, str) else v
|
|
|
|
|
|
class GitSourceOut(BaseModel):
|
|
"""One created git source as returned by ``POST`` (phase 35, task 02).
|
|
|
|
``id`` / ``added_at`` are non-null for a stored row. ``url`` is the
|
|
row's location column: the repo URL for ``kind=git`` rows and, for
|
|
``kind=local`` rows, the stored (expanded) directory path — the
|
|
phase-35 response shape is unchanged by phase 38, so a local 201
|
|
reports its path in ``url`` and the full row (``kind`` + ``path``)
|
|
via ``GET``.
|
|
"""
|
|
|
|
id: uuid.UUID | None
|
|
url: str
|
|
added_at: datetime | None
|
|
|
|
|
|
class GitSourceRow(BaseModel):
|
|
"""One row of ``GET /api/git-sources`` (phase 35; ``kind``/``path``,
|
|
phase 38, task 02).
|
|
|
|
``kind`` discriminates the row: git rows (and the git-only
|
|
``BOR_GIT_SOURCES`` env-fallback rows) carry ``url`` and
|
|
``path: null``; local rows carry ``path`` (the absolute directory,
|
|
expanded) and the same string in ``url`` (the table's NOT-NULL
|
|
location column). ``id`` / ``added_at`` are nullable: env-fallback
|
|
rows (table empty) carry neither.
|
|
"""
|
|
|
|
id: uuid.UUID | None
|
|
kind: Literal["git", "local"]
|
|
url: str
|
|
path: str | None
|
|
added_at: datetime | None
|
|
|
|
|
|
class GitSourceList(BaseModel):
|
|
"""``GET /api/git-sources`` response (phase 35, task 02).
|
|
|
|
``from_env`` is True only when the ``git_sources`` table is empty and
|
|
the list comes from ``BOR_GIT_SOURCES`` (the phase's locked fallback
|
|
— env rows are git-only and report ``kind: "git"``, ``path: null``);
|
|
once the table has rows the env var is ignored and ``from_env`` is
|
|
False — the UI is the source of truth.
|
|
"""
|
|
|
|
sources: list[GitSourceRow]
|
|
from_env: bool
|
|
|
|
|
|
class UploadOut(BaseModel):
|
|
"""The upload run's result fields (phase 49, task 02; phase 64, task 03).
|
|
|
|
Phase 64 (task 03): ``POST /api/git-sources/upload`` answers 202 the
|
|
moment the archive is on disk; these fields become the shape of
|
|
``GET /api/git-sources/upload/status`` ``detail`` on ``success`` —
|
|
the uploaded source's name (filename minus the archive suffix) plus
|
|
the SAME count keys as the admin sync's success ``detail``
|
|
(``files``, ``added``, ``updated``, ``unchanged``, ``pruned``,
|
|
``errors``, ``chunks`` — ``app.api.sync._run_sync``) and the
|
|
``overview`` flag: the Sources page renders the same
|
|
"N added · N pruned" result line for both.
|
|
"""
|
|
|
|
source: str
|
|
files: int
|
|
added: int
|
|
updated: int
|
|
unchanged: int
|
|
pruned: int
|
|
errors: int
|
|
chunks: int
|
|
overview: bool
|
|
|
|
|
|
class UploadAccepted(BaseModel):
|
|
"""``POST /api/git-sources/upload`` 202 response (phase 64, task 03).
|
|
|
|
The archive is **safely on disk** — this is the "successfully
|
|
uploaded" moment the Sources page toasts on (owner-locked A2). The
|
|
scan itself (unpack → swap → row upsert → model check → import →
|
|
overview) runs in a background task behind
|
|
``GET /api/git-sources/upload/status``, whose ``success`` ``detail``
|
|
carries the :class:`UploadOut` fields.
|
|
"""
|
|
|
|
detail: str = "upload received"
|
|
name: str
|
|
|
|
|
|
class ToolCall(BaseModel):
|
|
"""One agent tool-call record (the phase-37 ``tools`` record shape).
|
|
|
|
Mirrors the ``{name, argument}`` pair the SSE ``tool`` frames carry
|
|
(PLAN §4 extension; phase 70): ``argument`` is the single string
|
|
argument the model passed (``read``'s combined ``source/path``,
|
|
``grep``'s pattern, ``ls``'s scope) or null. Stored inside
|
|
:class:`ChatMessage.tools` so a saved chat restores the "calling
|
|
tool" lines pixel-identical (phase 50). Saved chats persisting the
|
|
pre-phase-70 tool names still validate — ``name`` is opaque
|
|
(no migration, locked).
|
|
"""
|
|
|
|
name: str
|
|
argument: str | None = None
|
|
|
|
|
|
class ChatMessage(BaseModel):
|
|
"""One conversation record in the ``bor.chat.v1`` localStorage shape
|
|
(phase 14) — the stored ``messages`` payload of a saved chat (phase 50).
|
|
|
|
``{who, text, sources?, deflected?, suggestions?, thinking?, tools?,
|
|
stopped?}`` — raw text, never HTML, so a saved chat restores
|
|
pixel-identical through the existing ``renderStoredMessage`` path.
|
|
``extra="forbid"`` rejects unknown keys (a corrupted or HTML-shaped
|
|
payload, e.g. a stray ``<b>``-ish extra key) at the boundary with a
|
|
422, so nothing outside this shape can poison a restored
|
|
conversation.
|
|
"""
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
who: Literal["user", "brain"]
|
|
text: str = Field(min_length=1)
|
|
sources: list[SourceRef] | None = None
|
|
deflected: bool | None = None
|
|
suggestions: list[str] | None = None
|
|
thinking: str | None = None
|
|
tools: list[ToolCall] | None = None
|
|
stopped: bool | None = None
|
|
|
|
|
|
class SavedChatCreate(BaseModel):
|
|
"""``POST /api/chats`` body (phase 50, task 02; ``share``, phase 51
|
|
task 02).
|
|
|
|
``title`` is optional: when absent or blank the API auto-titles the
|
|
row (the first user message's text, whitespace-collapsed, truncated
|
|
to 120 chars — the owner-locked convention). ``messages`` must be
|
|
non-empty — a saved chat with nothing to restore is meaningless.
|
|
|
|
``share`` (phase 51, owner-locked 2026-08-29): when true, the row is
|
|
shared in the SAME commit — ``share_token = uuid.uuid4()`` is set on
|
|
the fresh row before the INSERT, so one request saves AND shares
|
|
(the chat page's Share button on an unsaved conversation, the
|
|
save-then-share contract). The response then carries ``share_url``
|
|
(see :class:`SavedChatOut`). Default false — a plain Save is
|
|
unchanged by phase 51.
|
|
"""
|
|
|
|
title: str | None = Field(default=None, max_length=500)
|
|
messages: list[ChatMessage] = Field(min_length=1)
|
|
share: bool = False
|
|
|
|
|
|
class SavedChatUpdate(BaseModel):
|
|
"""``PUT /api/chats/{chat_id}`` body (phase 50, task 02).
|
|
|
|
``messages`` is a full replacement (the re-Save upsert semantics —
|
|
re-Saving the same conversation updates the same row, never a new
|
|
one). ``title`` is replaced only when supplied — an absent (or
|
|
blank) ``title`` keeps the row's current title.
|
|
"""
|
|
|
|
title: str | None = Field(default=None, max_length=500)
|
|
messages: list[ChatMessage] = Field(min_length=1)
|
|
|
|
|
|
def _drop_absent_share_url(model: BaseModel, handler: SerializerFunctionWrapHandler) -> Any:
|
|
"""The ``share_url`` omission rule (phase 51, task 02): ``None`` →
|
|
ABSENT from the JSON (not ``"share_url": null``) — an unshared chat
|
|
exposes no share surface at all, and the History column renders the
|
|
unshared state from the key's absence.
|
|
|
|
A ``mode="wrap"`` model serializer: the default (recursive) dump runs
|
|
first, then only the TOP-LEVEL key is dropped when null. The
|
|
recursion matters — a route-level ``response_model_exclude_none``
|
|
would also drop the nested ``ChatMessage`` nulls (``sources: null``
|
|
and friends), which the byte-identical round-trip contract (phase
|
|
50) forbids.
|
|
"""
|
|
data = handler(model)
|
|
if data.get("share_url") is None:
|
|
data.pop("share_url", None)
|
|
return data
|
|
|
|
|
|
class SavedChatOut(BaseModel):
|
|
"""One saved chat, full payload (create/get/put response, phase 50;
|
|
``share_url``, phase 51 task 02; ``stale``, phase 53 task 03).
|
|
|
|
``messages`` round-trips the ``bor.chat.v1`` record list losslessly
|
|
— the restore path is pixel-identical by construction.
|
|
|
|
``share_url`` (phase 51): ``"/shared/<token>"`` while the chat is
|
|
shared, ABSENT from the JSON when unshared (``None`` → dropped by
|
|
:func:`_drop_absent_share_url` — no ``null`` in the wire shape).
|
|
|
|
``stale`` (phase 53): true iff the row's ``sources_version`` stamp
|
|
is behind the current ``sources_meta`` generation — the answer
|
|
predates the latest KB-changing sync. Computed server-side (the
|
|
client never does staleness math); ``SharedChatOut`` deliberately
|
|
carries no staleness surface (the public snapshot is frozen by
|
|
design, phase 51).
|
|
"""
|
|
|
|
id: uuid.UUID
|
|
title: str
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
message_count: int
|
|
messages: list[ChatMessage]
|
|
share_url: str | None = None
|
|
#: Required (no default): the API must always compute staleness
|
|
#: server-side — there is no wire shape without the flag.
|
|
stale: bool
|
|
|
|
@model_serializer(mode="wrap")
|
|
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
|
return _drop_absent_share_url(self, handler)
|
|
|
|
|
|
class SavedChatRow(BaseModel):
|
|
"""One row of ``GET /api/chats`` (the History page's list shape,
|
|
phase 50; ``share_url``, phase 51 task 02).
|
|
|
|
No payloads in the list — the row carries only what the table needs
|
|
(Title, Messages count, Updated). ``share_url`` is populated here so
|
|
the History page's Share column renders straight from ``GET
|
|
/api/chats`` — no second fetch per row (``None`` → absent, the same
|
|
omission rule as :class:`SavedChatOut`). ``stale`` (phase 53) feeds
|
|
the History page's Stale column the same way: one ``GET`` powers
|
|
every column.
|
|
"""
|
|
|
|
id: uuid.UUID
|
|
title: str
|
|
updated_at: datetime
|
|
message_count: int
|
|
share_url: str | None = None
|
|
#: Required (no default) — see :attr:`SavedChatOut.stale`.
|
|
stale: bool
|
|
|
|
@model_serializer(mode="wrap")
|
|
def _serialize(self, handler: SerializerFunctionWrapHandler) -> Any:
|
|
return _drop_absent_share_url(self, handler)
|
|
|
|
|
|
class SavedChatList(BaseModel):
|
|
"""``GET /api/chats`` response: saved chats, latest activity first
|
|
(``updated_at desc, id desc``)."""
|
|
|
|
chats: list[SavedChatRow]
|
|
|
|
|
|
class SharedChatOut(BaseModel):
|
|
"""``GET /api/shared/{token}`` body (phase 51, task 01) — the PUBLIC
|
|
read shape of a shared chat.
|
|
|
|
Deliberately minimal: ``title`` + ``messages`` only. No id, no
|
|
timestamps, no token, no ``message_count`` — a shared chat is a
|
|
content snapshot, not a handle: nothing in the body can be turned
|
|
back into an admin-surface request, and the token itself never
|
|
round-trips (it is the URL, not data).
|
|
"""
|
|
|
|
title: str
|
|
messages: list[ChatMessage]
|
|
|
|
|
|
class ShareOut(BaseModel):
|
|
"""``POST /api/chats/{chat_id}/share`` response (phase 51, task 01).
|
|
|
|
``share_url`` is the path (``/shared/<token>``) the UI copies into
|
|
the clipboard — the owner's own origin supplies the scheme/host.
|
|
Idempotent: a re-share returns the existing, unchanged token.
|
|
"""
|
|
|
|
chat_id: uuid.UUID
|
|
share_url: str
|
|
|
|
|
|
class UnshareOut(BaseModel):
|
|
"""``POST /api/chats/{chat_id}/unshare`` response (phase 51, task 01).
|
|
|
|
``shared: false`` is reported unconditionally — the endpoint is
|
|
idempotent, so an already-unshared chat unshares cleanly (200).
|
|
"""
|
|
|
|
chat_id: uuid.UUID
|
|
shared: bool
|
|
|
|
|
|
class DocDraftCreate(BaseModel):
|
|
"""``POST /api/doc-drafts`` body (phase 59, task 02): one completed
|
|
chat answer about to become documentation.
|
|
|
|
``title`` arrives client-side as the last user question
|
|
(whitespace-collapsed, ≤120 chars — the chat auto-title convention,
|
|
phase 50); ``path`` as ``docs/<slug>.md``; ``body`` is the answer's
|
|
raw markdown (never HTML — the ``bor.chat.v1`` record's ``text``,
|
|
the phase-50/51 round-trip convention). The path guard-rails (task
|
|
02 — repo-relative, no ``..``, no absolute path) run in the API
|
|
layer so the 422 details stay fixed strings; the max lengths mirror
|
|
the ``documents`` table (title 500, path 1000).
|
|
"""
|
|
|
|
title: str = Field(min_length=1, max_length=500)
|
|
path: str = Field(min_length=1, max_length=1000)
|
|
body: str = Field(min_length=1)
|
|
|
|
|
|
class DocDraftUpdate(BaseModel):
|
|
"""``PUT /api/doc-drafts/{token}`` body (phase 59, task 02): a
|
|
partial update — each field is replaced only when supplied (absent
|
|
keeps the row's current value; present must be non-empty — the
|
|
``SavedChatUpdate`` optional-title pattern, extended to all three
|
|
editable fields). The same path guard-rails as create run in the
|
|
API layer when ``path`` is supplied.
|
|
"""
|
|
|
|
title: str | None = Field(default=None, min_length=1, max_length=500)
|
|
path: str | None = Field(default=None, min_length=1, max_length=1000)
|
|
body: str | None = Field(default=None, min_length=1)
|
|
|
|
|
|
class DocDraft(BaseModel):
|
|
"""One draft row, full payload (create/get/put response, phase 59).
|
|
|
|
``token`` is the URL credential (``/doc-edit.html?draft=<token>``
|
|
— the unguessable ``uuid4``, the share-token trust model, phase
|
|
51). ``status`` is ``draft`` until the push endpoint commits +
|
|
pushes the file, then ``pushed`` with ``branch`` / ``commit_sha``
|
|
recorded (both NULL while still a draft). Datetimes serialize
|
|
ISO-8601 on the wire (pydantic default).
|
|
"""
|
|
|
|
token: uuid.UUID
|
|
title: str
|
|
path: str
|
|
body: str
|
|
status: str
|
|
branch: str | None = None
|
|
commit_sha: str | None = None
|
|
created_at: datetime
|
|
updated_at: datetime
|
|
|
|
|
|
class DocDraftPushed(BaseModel):
|
|
"""``POST /api/doc-drafts/{token}/push`` success response (phase 59,
|
|
task 04): the commit + ``git push --ff-only`` landed — ``branch``
|
|
is the ``BOR_DOCS_BRANCH`` name and ``commit_sha`` the pushed
|
|
branch's new HEAD (the edit screen's branch + sha feedback; it must
|
|
equal ``git rev-parse <branch>`` in the repo — the E2E source of
|
|
truth is the bare repo's state, not the UI alone).
|
|
"""
|
|
|
|
status: Literal["pushed"] = "pushed"
|
|
branch: str
|
|
commit_sha: str
|
|
|
|
|
|
class TokenCreateRequest(BaseModel):
|
|
"""``POST /api/tokens`` body (phase 79, task 02): one named token
|
|
to generate and hand out.
|
|
|
|
``label`` is the hand-out name (e.g. "alice") — display-only, NOT
|
|
unique (two tokens may share a label). Trimmed *before* the length
|
|
constraints run, so a whitespace-only body is a 422 and a label with
|
|
surrounding spaces is stored clean (the ``SteeringNoteIn`` house
|
|
``ValueError`` pattern — fail loud at the boundary).
|
|
"""
|
|
|
|
label: str = Field(min_length=1, max_length=120)
|
|
|
|
@field_validator("label", mode="before")
|
|
@classmethod
|
|
def _trim_label(cls, v: object) -> object:
|
|
return v.strip() if isinstance(v, str) else v
|
|
|
|
|
|
class TokenCreated(BaseModel):
|
|
"""``POST /api/tokens`` 201 response (phase 79, task 02).
|
|
|
|
The ONLY schema in the codebase that carries the plaintext ``token``
|
|
— the wire moment it exists exactly once (A4). Every other response
|
|
shape (the list row, whoami, …) exposes display fields only: the
|
|
stored credential is the hash, and the hash itself is never a wire
|
|
field either.
|
|
"""
|
|
|
|
id: uuid.UUID
|
|
label: str
|
|
token: str
|
|
created_at: datetime
|
|
|
|
|
|
class TokenListItem(BaseModel):
|
|
"""One row of ``GET /api/tokens`` (phase 79, task 02).
|
|
|
|
Deliberately secret-free: NO ``token`` field and NO ``token_hash``
|
|
field exist on this shape — the list never carries a credential in
|
|
either form (A4). ``revoked`` is derived server-side from
|
|
``revoked_at is not None`` (the UI renders the Active/Revoked state
|
|
from the flag, not the timestamp).
|
|
"""
|
|
|
|
id: uuid.UUID
|
|
label: str
|
|
created_at: datetime
|
|
last_used_at: datetime | None
|
|
revoked: bool
|
|
|
|
|
|
class TokenList(BaseModel):
|
|
"""``GET /api/tokens`` response: all tokens, newest first
|
|
(``created_at desc, id desc``)."""
|
|
|
|
tokens: list[TokenListItem]
|
|
|
|
|
|
class TokenAuthRequest(BaseModel):
|
|
"""``POST /api/token-auth`` body (phase 79, task 03): the plaintext
|
|
token a handed-out user presents at the in-app gate.
|
|
|
|
Deliberately NO min-length validator: an empty/whitespace token is a
|
|
MALFORMED login attempt — the endpoint 401s ``invalid token`` (the
|
|
phase-16 generic-401 pattern, one message for every failure: no
|
|
enumeration). A 422 here would hint at input-shape differences on a
|
|
credential endpoint, so the shape is just ``str`` and the endpoint
|
|
owns the ``token.strip()`` check.
|
|
"""
|
|
|
|
token: str
|