Compare commits

..

7 Commits

Author SHA1 Message Date
vvzvlad 624e03a9e3 Merge pull request 'feat(comments): редизайн панели комментариев — обе карточки на новом визуале (#561)' (#577) from feat/561-comments-redesign into develop
Reviewed-on: #577
2026-07-12 23:22:43 +03:00
agent_coder be8ed579a9 fix(comments): review fixes for the edit-card redesign (#561)
- F1: restore the "Applied" badge on an applied suggested-edit card (green light
  Badge + aria-label, reusing the pre-redesign t("Applied") key). Renders only
  when suggestionAppliedAt is set (a reply-kept, resolved suggestion — a
  childless applied one is hard-deleted per #329), never for a pending edit.
- F2: add the four new t() keys introduced by the redesign to both locales —
  "Critical", "Major", "{{count}} edits", "{{count}} major" (en value==key; ru
  translated, ICU plural for the count keys, placeholders intact).
- F3: test RunHeader — edit count, major = major+critical only (not
  minor/unknown, non-vacuous), provenance line, no "Accept all" control. Uses an
  isolated initialised i18n instance so the interpolated count keys render.
- F4: restore the card aria-label (t("Jump to comment selection")) on the
  clickable edit card for screen readers.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:14:01 +03:00
agent_coder d542fc58ae feat(comments): интеграция редизайна панели комментариев (обе карточки) (#561)
Port the NewDesign/CommentsPanel prototype onto the entire live comment panel,
both card types (agent suggested-edit + human/agent thread) on the new visual,
reusing existing mutations/gates/editor/menu/resolve/anchor-nav (not forked),
zero backend changes. New: agent-edit-card, severity.ts, group-agent-runs.ts.
Delete only the CommentsPanel prototype (TimeWorkedModal/PageHistoryModal belong
to #566/#568 and are deleted by their own PRs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 23:14:01 +03:00
agent_vscode 85ffdea06b Merge remote-tracking branch 'gitea/develop' into develop 2026-07-12 21:35:41 +03:00
agent_vscode 9aa7620461 fix(client): номер сноски встаёт инлайн, а не на отдельной строке
Номер «N.» рисовался через .definitionContent > :first-child::before, но
tiptap-react оборачивает содержимое NodeViewContent в дополнительный блочный
div [data-node-view-content-react], поэтому :first-child — это обёртка, а не
<p>. Инлайновый ::before на блочной обёртке с блочным <p> внутри падал на
свою строку (регрессия «+1 строка» из #420). Селекторы переведены на
.definitionContent p:first-child / p:last-child — номер и сброс полей теперь
бьют по самому абзацу (контент определения — paragraph+, вложенных абзацев
нет, так что попадание однозначно), работает и с обёрткой, и без неё.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 21:35:37 +03:00
vvzvlad 710a690c78 Merge pull request 'feat(search) Фаза B PR-1: семантический слой — TEI e5-small + гибрид вектор+RRF (#530)' (#567) from feat/530-search-semantic into develop
Reviewed-on: #567
2026-07-12 20:39:43 +03:00
agent_coder 1fc9c25681 feat(search): semantic fusion layer (Phase B PR-1) over lexical RRF (#530)
Fuse a vector-similarity branch into #529's lexical RRF. Query embed via a
global TEI sidecar (or per-workspace provider), degrading transparently to the
byte-identical Phase-A lexical path on any failure; a kill-switch and env knobs
gate it.

- ai.service: resolveEmbeddingProvider (workspace→global TEI fallback) with a
  deterministic config fingerprint; embedQuery (query prefix + short 800ms
  timeout); extract embedWithModel core shared with embedTexts.
- page-embedding.repo: vectorCandidateArm fragment (page-level NN, dim +
  active-fingerprint filtered) + fingerprint on insertChunks.
- search.service: 3-branch RRF over lexical ∪ vector candidates; try/catch wraps
  only the embed (permission filter stays outside, fail-closed); semantic
  degrade emits search.semantic.degraded; response gains semantic{state,available,reason}.
- indexer: resolve provider, prepend doc prefix, stamp fingerprint per row.
- migration 20260712T120000: add nullable page_embeddings.fingerprint + composite index.
- infra: TEI embeddings sidecar in docker-compose + EMBEDDING_*/SEARCH_* in .env.example.
- tests: 7 semantic int cases (vector-only hit, sidecar-down, no-provider,
  permission-over-union fail-closed, hung sidecar, lexical∪vector de-dup,
  fingerprint isolation) + fingerprint/prefix/timeout unit tests.

total now = permission-filtered size of (lexical ∪ vector top-N) — a documented
change from Phase A's exact lexical count (falls back to it on degrade).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 20:21:19 +03:00
51 changed files with 2772 additions and 1586 deletions
+46
View File
@@ -198,6 +198,42 @@ MCP_DOCMOST_PASSWORD=
# A slow/hung embeddings endpoint fails after this and the batch continues.
# AI_EMBEDDING_TIMEOUT_MS=120000
# ─── #530 Semantic search (Phase B) ──────────────────────────────────────────
# The GLOBAL embedding provider used by search (query embed) AND the indexer
# (document embed) when a workspace has NO embedding provider of its own. It is
# an OpenAI-compatible endpoint — the docker-compose `embeddings` (TEI) sidecar.
# A workspace that configures its own embedding provider OVERRIDES all of this.
# When neither resolves, search runs lexical-only (semantic.reason=no-provider).
EMBEDDING_ENDPOINT=http://embeddings:80/v1
EMBEDDING_MODEL=intfloat/multilingual-e5-small
# Dummy — the self-hosted TEI sidecar is keyless.
EMBEDDING_API_KEY=unused
EMBEDDING_DIMENSIONS=384
# PLACEHOLDER — set to a PINNED intfloat/multilingual-e5-small commit sha (used
# both as the TEI --revision and as part of the embedding fingerprint). Keep this
# in lockstep with docker-compose; a bump changes the fingerprint (PR-2 handles
# the generational swap/GC — PR-1 uses a single active fingerprint).
EMBEDDING_REVISION=REPLACE_WITH_PINNED_E5_SMALL_COMMIT_SHA
# e5 models require these input prefixes. Empty them for a non-e5 model.
EMBEDDING_QUERY_PREFIX="query: "
EMBEDDING_DOC_PREFIX="passage: "
# Per-request timeout (ms) for the interactive SEARCH query embed — much shorter
# than the batch indexer timeout: a slow/hung sidecar degrades search fast to the
# lexical-only path instead of blocking the request. Default 800.
# SEARCH_EMBED_TIMEOUT_MS=800
# RRF weight of the vector leg relative to each lexical leg (1.0 = equal). Default 1.0.
# SEARCH_VECTOR_WEIGHT=1.0
# Vector top-N pulled into the fused candidate union per request. Default 50.
# SEARCH_VECTOR_CANDIDATES=50
# Per-statement timeout (ms) bounding ONLY the fused vector query (the brute-force
# KNN seq scan — there is no ANN index). If the vector scan exceeds this it is
# cancelled and search degrades to lexical-only (never hangs the request). Scoped
# per-query (SET LOCAL), so it never affects other queries. Default 2000.
# SEARCH_VECTOR_STATEMENT_TIMEOUT_MS=2000
# Kill-switch: set to `off` to disable the semantic layer entirely (search then
# runs the byte-identical Phase-A lexical path). Default on.
# SEARCH_SEMANTIC=on
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
@@ -251,6 +287,16 @@ MCP_DOCMOST_PASSWORD=
# Default 120000 (2 min).
# AI_MCP_CALL_TIMEOUT_MS=120000
# Kill-switch for the agent API-key feature (#501). Default ON when unset — a
# deploy that never sets it must NOT silently kill every agent. STRICT parse:
# only the literals `true` / `false` are accepted; a typo like `=0`/`=off`/`=False`
# FAILS AT BOOT by design (never silently read as "enabled"), so the switch is
# guaranteed to actually flip when an operator flips it during an incident. When
# set to `false`: all api-key auth is DENIED (every api-key token is rejected) and
# the api-key management endpoints return 404. The resolved state is logged at boot
# (`API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`) so it is verifiable per deploy.
# API_KEYS_ENABLED=true
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
# small for a long AI-chat research turn: the client resends the FULL message
# history (every tool call + search result) on each turn, so a deep conversation's
-460
View File
@@ -1,460 +0,0 @@
/**
* CommentsPanel — редизайн панели комментариев с фокусом на агентские правки.
* Mantine v7. Узкая колонка ~340–400px, светлая/тёмная темы.
*
* Реализованные решения (см. README.md):
* - Дифф-первая карточка: цитата = старая строка диффа (без тройного дублирования)
* - Группировка серии прогона под одной шапкой ("Корректор · 28 правок")
* - Пакетное "Accept all" на фронте (по одной под капотом) с полоской прогресса
* - Метки важности/категории парсятся клиентом из тегов "[Корректура][Существенно]"
* - Фильтры по важности/категории
* - Безопасный Dismiss через undo-тост (удаление откладывается на клиенте)
* - Состояния: pending / applied / dismissed / conflict (потерян якорь)
* - Крайние диффы: вставка (␣), удаление, одна буква, дефис→тире, длинный абзац
* - Человеческий тред не деградирует (та же система, другое наполнение)
* - Вкладки Open/Resolved сохранены
*/
import { useMemo, useState, useCallback, useRef } from 'react';
import {
Box, Group, Stack, Text, Badge, Button, ActionIcon, Tabs, Progress,
Avatar, Tooltip, ScrollArea, useMantineColorScheme, useMantineTheme,
} from '@mantine/core';
import { notifications } from '@mantine/notifications';
/* ─────────────────────────── Типы данных ─────────────────────────── */
export type Severity = 'critical' | 'major' | 'minor';
export type Category = string; // "Корректура" | "Факт" | "Стиль" | …
/** Один сегмент интра-диффа: изменённый фрагмент подсвечивается. */
export interface DiffSegment {
text: string;
changed: boolean;
}
/** Правка: сервер уже отдаёт посегментную разметку обеих строк. */
export interface SuggestedEdit {
before: DiffSegment[]; // "было" (пустой массив ⇒ чистая вставка)
after: DiffSegment[]; // "стало" (пустой массив ⇒ чистое удаление)
}
export type CommentStatus = 'pending' | 'applied' | 'dismissed' | 'conflict';
export interface Comment {
id: string;
runId?: string; // id прогона агента — для группировки серии
authorName: string; // "Корректор" | "Нарратор" | имя человека
authorKind: 'agent' | 'human';
triggeredBy?: string; // кто запустил агента ("vvzvlad")
createdAtLabel: string; // "15 ч", "2 дн" — форматируется вызывающим кодом
/** Сырой текст комментария от агента, метки в квадратных скобках. */
bodyRaw: string;
edit?: SuggestedEdit; // есть ⇒ агентская правка; нет ⇒ обычный тред
status: CommentStatus;
replyCount?: number;
anchorLost?: boolean; // сервер ответил конфликтом якоря
}
export interface CommentsPanelProps {
comments: Comment[];
/** Применить одну правку. Резолвит тред на сервере. */
onApply: (id: string) => Promise<void>;
/** Жёсткое удаление на сервере (для треда без ответов). Вызывается ПОСЛЕ окна undo. */
onDismiss: (id: string) => Promise<void>;
/** Резолв обычного треда. */
onResolve?: (id: string) => Promise<void>;
/** Клик по карточке/цитате — скролл документа к якорю и подсветка. */
onNavigateToAnchor: (id: string) => void;
onClose?: () => void;
/** Мс до фактического удаления после Dismiss (окно undo). По умолчанию 5000. */
dismissUndoMs?: number;
}
/* ───────────────────── Клиентский парсинг меток ───────────────────── */
const SEVERITY_WORDS: Record<string, Severity> = {
'существенно': 'major', 'критично': 'critical', 'критическая': 'critical',
'незначительно': 'minor', 'мелко': 'minor', 'major': 'major',
'minor': 'minor', 'critical': 'critical',
};
interface ParsedBody {
category?: Category;
severity: Severity;
text: string; // тело без скобочных тегов
}
/** "[Корректура] [Существенно] Пробел…" → {category, severity, text}. */
export function parseBody(raw: string): ParsedBody {
const tags: string[] = [];
const text = raw.replace(/\[([^\]]+)\]/g, (_, t) => { tags.push(t.trim()); return ''; }).trim();
let severity: Severity = 'minor';
let category: Category | undefined;
for (const t of tags) {
const sv = SEVERITY_WORDS[t.toLowerCase()];
if (sv) severity = sv; else if (!category) category = t;
}
return { category, severity, text };
}
const SEV_COLOR: Record<Severity, string> = {
critical: 'red', major: 'orange', minor: 'gray',
};
/* ──────────────────────────── Дифф ──────────────────────────── */
/** Делает невидимые символы видимыми в подсветке (пробел, таб). */
function visibleWhitespace(s: string): string {
return s.replace(/ /g, '␣').replace(/\t/g, '⇥');
}
function DiffLine({ segments, kind }: { segments: DiffSegment[]; kind: 'del' | 'ins' }) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === 'dark';
const isDel = kind === 'del';
const sign = isDel ? '−' : '+';
const signColor = isDel ? theme.colors.red[dark ? 5 : 6] : theme.colors.green[dark ? 5 : 6];
const baseColor = isDel
? (dark ? theme.colors.gray[5] : theme.colors.gray[6])
: (dark ? theme.colors.gray[1] : theme.colors.dark[9]);
const markBg = isDel
? (dark ? 'rgba(224,49,49,.22)' : '#ffe3e3')
: (dark ? 'rgba(47,158,68,.22)' : '#d3f9d8');
const markFg = isDel
? (dark ? theme.colors.red[3] : theme.colors.red[8])
: (dark ? theme.colors.green[3] : theme.colors.green[9]);
return (
<Group gap={7} wrap="nowrap" align="flex-start">
<Text ff="monospace" fw={600} fz={12} c={signColor} w={11} ta="center" style={{ flex: 'none', lineHeight: 1.5 }}>{sign}</Text>
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
{segments.map((seg, i) =>
seg.changed ? (
<Box key={i} component="mark" px={3} fw={600}
style={{ background: markBg, color: markFg, borderRadius: 3,
textDecoration: isDel ? 'line-through' : 'none' }}>
{visibleWhitespace(seg.text)}
</Box>
) : (
<Box key={i} component="span" style={{ textDecoration: isDel ? 'line-through' : 'none' }}>{seg.text}</Box>
)
)}
</Text>
</Group>
);
}
function DiffBlock({ edit }: { edit: SuggestedEdit }) {
const pureInsert = edit.before.length === 0;
const pureDelete = edit.after.length === 0;
return (
<Stack gap={1}>
{!pureInsert && <DiffLine segments={edit.before} kind="del" />}
{!pureDelete && <DiffLine segments={edit.after} kind="ins" />}
</Stack>
);
}
/* ──────────────────────── Карточка правки ──────────────────────── */
function EditCard({ c, onApply, onDismiss, onNavigateToAnchor, dismissUndoMs = 5000 }: {
c: Comment;
onApply: CommentsPanelProps['onApply'];
onDismiss: CommentsPanelProps['onDismiss'];
onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor'];
dismissUndoMs?: number;
}) {
const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]);
const [busy, setBusy] = useState(false);
const timer = useRef<number>();
const apply = useCallback(async () => {
setBusy(true);
try { await onApply(c.id); } finally { setBusy(false); }
}, [c.id, onApply]);
// Безопасный Dismiss: прячем сразу, удаляем на сервере после окна undo.
const dismiss = useCallback(() => {
let undone = false;
const nid = notifications.show({
message: 'Edit dismissed',
color: 'gray',
autoClose: dismissUndoMs,
withCloseButton: false,
// Кнопка "Вернуть" — см. README про кастомный рендер экшена.
});
timer.current = window.setTimeout(() => {
if (!undone) onDismiss(c.id);
}, dismissUndoMs);
// undo вызывается из UI: clearTimeout(timer.current); undone = true;
return { nid, cancel: () => { undone = true; clearTimeout(timer.current); } };
}, [c.id, onDismiss, dismissUndoMs]);
const conflict = c.status === 'conflict' || c.anchorLost;
return (
<Box
p="10px 12px"
onClick={() => onNavigateToAnchor(c.id)}
style={(t) => ({
cursor: 'pointer',
borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`,
})}
>
<Stack gap={8}>
{c.edit && <DiffBlock edit={c.edit} />}
{conflict && (
<Group gap={7} wrap="nowrap" p="6px 8px"
style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(230,180,20,.12)' : '#fff9db', borderRadius: 7 })}>
<Text fz={12}></Text>
<Text fz={12} c="yellow.8" style={{ lineHeight: 1.4 }}>Text changed this edit cant be applied</Text>
</Group>
)}
{parsed.text && !conflict && (
<Text fz={12.5} c="dimmed" style={{ lineHeight: 1.45 }}>{parsed.text}</Text>
)}
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<Box w={8} h={8} style={(t) => ({ flex: 'none', borderRadius: '50%', background: t.colors[SEV_COLOR[parsed.severity]][6] })} />
<Text fz={10} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.06em', flex: 1, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{[parsed.category, parsed.severity === 'major' ? 'Major' : parsed.severity === 'critical' ? 'Critical' : null].filter(Boolean).join(' · ')}
</Text>
{c.status === 'applied' ? (
<Badge color="green" variant="light" radius="xl" size="sm"> Applied</Badge>
) : c.status === 'dismissed' ? (
<Badge color="gray" variant="light" radius="xl" size="sm">Dismissed</Badge>
) : conflict ? (
<Button size="compact-sm" variant="default" onClick={() => onNavigateToAnchor(c.id)}>Go to text</Button>
) : (
<>
<Button size="compact-sm" variant="default" color="gray" onClick={dismiss}>Dismiss</Button>
<Button size="compact-sm" color="green" loading={busy} onClick={apply}>Apply</Button>
</>
)}
</Group>
</Stack>
</Box>
);
}
/* ──────────────────────── Человеческий тред ──────────────────────── */
function HumanThread({ c, onResolve, onNavigateToAnchor }: {
c: Comment; onResolve?: CommentsPanelProps['onResolve']; onNavigateToAnchor: CommentsPanelProps['onNavigateToAnchor'];
}) {
const parsed = useMemo(() => parseBody(c.bodyRaw), [c.bodyRaw]);
return (
<Box p="12px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Stack gap={9}>
<Group gap={8} wrap="nowrap">
<Avatar size={26} radius="xl" color="orange">{c.authorName[0]}</Avatar>
<Text fz={12.5} fw={600}>{c.authorName}
<Text span c="dimmed" fw={400}> · {c.triggeredBy ? `${c.triggeredBy} · ` : ''}{c.createdAtLabel}</Text>
</Text>
<Box style={{ flex: 1 }} />
<Tooltip label="Resolve"><ActionIcon variant="default" size="md" onClick={() => onResolve?.(c.id)}></ActionIcon></Tooltip>
<ActionIcon variant="default" size="md"></ActionIcon>
</Group>
<Text fz={13.5} style={{ lineHeight: 1.5 }}>{parsed.text}</Text>
<Group gap={8}>
{c.replyCount ? <Text fz={12} c="dimmed">{c.replyCount} replies</Text> : null}
<Box style={{ flex: 1 }} />
<Button variant="subtle" size="compact-sm">Reply</Button>
</Group>
</Stack>
</Box>
);
}
/* ──────────────────── Шапка серии + прогресс ──────────────────── */
function RunHeader({ runComments, onAcceptAll, progress }: {
runComments: Comment[];
onAcceptAll: () => void;
progress?: { done: number; total: number } | null;
}) {
const head = runComments[0];
const majors = runComments.filter((c) => parseBody(c.bodyRaw).severity !== 'minor').length;
const applied = runComments.filter((c) => c.status === 'applied').length;
return (
<Box>
<Group gap={10} wrap="nowrap" p="11px 13px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Box style={{ position: 'relative', width: 30, height: 30, flex: 'none' }}>
<Avatar size={30} radius={8} color="teal">{head.authorName[0]}</Avatar>
{head.triggeredBy && (
<Avatar size={14} radius="xl" color="gray"
style={{ position: 'absolute', right: -4, bottom: -4, border: '2px solid var(--mantine-color-body)' }} />
)}
</Box>
<Stack gap={1} style={{ minWidth: 0 }}>
<Text fz={13} fw={600}>{head.authorName}
<Text span c="dimmed" fw={400}> · {head.triggeredBy} · {head.createdAtLabel}</Text>
</Text>
<Text fz={11.5} c="dimmed">
{runComments.length} edits · <Text span c="orange.7" fw={600}>{majors} major</Text> · {applied} applied
</Text>
</Stack>
<Box style={{ flex: 1 }} />
{!progress && <Button size="compact-sm" color="green" onClick={onAcceptAll}>Accept all</Button>}
</Group>
{progress && (
<Box p="9px 13px 11px" style={(t) => ({ background: t.colorScheme === 'dark' ? 'rgba(47,158,68,.08)' : '#f8fbf9', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
<Group gap={8} mb={6}>
<Text fz={12} fw={600} c="green.7">Applying {progress.done} of {progress.total}</Text>
<Box style={{ flex: 1 }} />
<Button variant="subtle" color="gray" size="compact-xs">Stop</Button>
</Group>
<Progress value={(progress.done / progress.total) * 100} color="green" size="sm" radius="xl" />
</Box>
)}
</Box>
);
}
/* ──────────────────────────── Панель ──────────────────────────── */
/** Роль-автор для фильтра: у агентов — имя роли, у людей — «Пользователь». */
function roleOf(c: Comment): string {
return c.authorKind === 'human' ? 'Пользователь' : c.authorName;
}
export function CommentsPanel(props: CommentsPanelProps) {
const { comments, onApply, onDismiss, onResolve, onNavigateToAnchor, onClose, dismissUndoMs } = props;
const [tab, setTab] = useState<'open' | 'resolved'>('open');
const [roleFilter, setRoleFilter] = useState<string | null>(null);
const [progress, setProgress] = useState<Record<string, { done: number; total: number }>>({});
const open = comments.filter((c) => c.status === 'pending' || c.status === 'conflict');
const resolved = comments.filter((c) => c.status === 'applied' || c.status === 'dismissed');
const list = tab === 'open' ? open : resolved;
const filtered = list.filter((c) => !roleFilter || roleOf(c) === roleFilter);
// Роли и счётчики для чипов-фильтров (только роли: Корректор / Фактчекер / Пользователь …).
const roles = useMemo(() => {
const order: string[] = [];
const count: Record<string, number> = {};
for (const c of list) {
const r = roleOf(c);
if (!(r in count)) { count[r] = 0; order.push(r); }
count[r]++;
}
return order.map((r) => ({ role: r, count: count[r] }));
}, [list]);
// Группировка агентских правок по runId; человеческие треды — по одному.
const groups = useMemo(() => {
const map = new Map<string, Comment[]>();
const singles: Comment[] = [];
for (const c of filtered) {
if (c.authorKind === 'agent' && c.runId && c.edit) {
if (!map.has(c.runId)) map.set(c.runId, []);
map.get(c.runId)!.push(c);
} else singles.push(c);
}
return { runs: [...map.entries()], singles };
}, [filtered]);
// Пакетное "Accept all" — по одной на фронте, обновляем прогресс.
const acceptAll = useCallback(async (runId: string, items: Comment[]) => {
const minor = items.filter((c) => parseBody(c.bodyRaw).severity === 'minor' && c.status === 'pending');
for (let i = 0; i < minor.length; i++) {
setProgress((p) => ({ ...p, [runId]: { done: i, total: minor.length } }));
try { await onApply(minor[i].id); } catch { /* конфликт — пропускаем, копим для сводки */ }
}
setProgress((p) => { const n = { ...p }; delete n[runId]; return n; });
notifications.show({ color: 'green', message: `${minor.length} applied` });
}, [onApply]);
return (
<Stack gap={0} h="100%" style={{ width: 380, maxWidth: '100%', borderLeft: '1px solid var(--mantine-color-default-border)' }}>
{/* Вкладки — статусная ось. Open/Resolved сохранены. */}
<Group gap={4} p="10px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[2]}` })}>
<Tabs value={tab} onChange={(v) => setTab(v as 'open' | 'resolved')} variant="pills">
<Tabs.List>
<Tabs.Tab value="open" rightSection={<Badge size="sm" variant="light" color="blue">{open.length}</Badge>}>Open</Tabs.Tab>
<Tabs.Tab value="resolved" rightSection={<Badge size="sm" variant="light" color="gray">{resolved.length}</Badge>}>Resolved</Tabs.Tab>
</Tabs.List>
</Tabs>
<Box style={{ flex: 1 }} />
{onClose && <ActionIcon variant="subtle" color="gray" onClick={onClose}></ActionIcon>}
</Group>
{/* Фильтры-чипы: только по ролям (Корректор / Фактчекер / Пользователь). */}
{tab === 'open' && roles.length > 1 && (
<ScrollArea type="never" p="8px 14px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
<Group gap={6} wrap="nowrap">
<FilterChip active={!roleFilter} onClick={() => setRoleFilter(null)} label={`All ${list.length}`} solid />
{roles.map(({ role, count }) => (
<FilterChip key={role} active={roleFilter === role} onClick={() => setRoleFilter(roleFilter === role ? null : role)} label={`${role} ${count}`} />
))}
</Group>
</ScrollArea>
)}
{/* Лента */}
<ScrollArea style={{ flex: 1 }} bg="var(--mantine-color-default-hover)">
{filtered.length === 0 ? (
<EmptyState tab={tab} />
) : (
<>
{groups.runs.map(([runId, items]) => (
<Box key={runId} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
<RunHeader runComments={items} progress={progress[runId] ?? null} onAcceptAll={() => acceptAll(runId, items)} />
{items.map((c) => (
<EditCard key={c.id} c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
))}
</Box>
))}
{groups.singles.map((c) => (
<Box key={c.id} m="6px 10px" bg="var(--mantine-color-body)" style={{ border: '1px solid var(--mantine-color-default-border)', borderRadius: 10, overflow: 'hidden' }}>
{c.edit
? <EditCard c={c} onApply={onApply} onDismiss={onDismiss} onNavigateToAnchor={onNavigateToAnchor} dismissUndoMs={dismissUndoMs} />
: <HumanThread c={c} onResolve={onResolve} onNavigateToAnchor={onNavigateToAnchor} />}
</Box>
))}
</>
)}
</ScrollArea>
</Stack>
);
}
/* ─────────────────────── Мелкие компоненты ─────────────────────── */
function FilterChip({ label, active, onClick, dot, solid }: {
label: string; active: boolean; onClick: () => void; dot?: string; solid?: boolean;
}) {
return (
<Button
onClick={onClick}
size="compact-sm"
radius="xl"
variant={active ? 'filled' : 'default'}
color={active ? (solid ? 'dark' : 'blue') : 'gray'}
leftSection={dot ? <Box w={7} h={7} style={(t) => ({ borderRadius: '50%', background: t.colors[dot][6] })} /> : undefined}
styles={{ root: { flex: 'none' }, label: { fontWeight: 600, fontSize: 12 } }}
>
{label}
</Button>
);
}
function EmptyState({ tab }: { tab: 'open' | 'resolved' }) {
const isOpen = tab === 'open';
return (
<Stack align="center" gap={6} p="40px 20px">
<Avatar size={40} radius="xl" color={isOpen ? 'green' : 'gray'}>{isOpen ? '✓' : '◌'}</Avatar>
<Text fw={600} fz={13}>{isOpen ? 'All caught up' : 'Nothing here yet'}</Text>
<Text fz={12} c="dimmed" ta="center" style={{ lineHeight: 1.45 }}>
{isOpen ? 'No edits waiting on you.' : 'Applied and closed items will appear here.'}
</Text>
</Stack>
);
}
export default CommentsPanel;
@@ -13,16 +13,8 @@
"Add space members": "Add space members",
"Add to favorites": "Add to favorites",
"Admin": "Admin",
"API key copied to clipboard": "API key copied to clipboard",
"API key created": "API key created",
"API key revoked": "API key revoked",
"Confirm your password": "Confirm your password",
"Copy API key": "Copy API key",
"Copy to clipboard": "Copy to clipboard",
"Copy {{name}}": "Copy {{name}}",
"Enter your password to copy the API key \"{{name}}\" to your clipboard.": "Enter your password to copy the API key \"{{name}}\" to your clipboard.",
"Failed to copy API key": "Failed to copy API key",
"Incorrect password": "Incorrect password",
"API keys across the workspace.": "API keys across the workspace.",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Are you sure you want to delete this group? Members will lose access to resources this group has access to.",
"Are you sure you want to delete this page?": "Are you sure you want to delete this page?",
@@ -56,6 +48,7 @@
"Create page": "Create page",
"Create space": "Create space",
"Create workspace": "Create workspace",
"Critical": "Critical",
"Current password": "Current password",
"Dark": "Dark",
"Date": "Date",
@@ -133,6 +126,7 @@
"Link copied": "Link copied",
"Login": "Login",
"Logout": "Logout",
"Major": "Major",
"Manage Group": "Manage Group",
"Manage members": "Manage members",
"member": "member",
@@ -461,6 +455,8 @@
"{{count}} Columns": "{{count}} Columns",
"{{count}} command available_one": "1 command available",
"{{count}} command available_other": "{{count}} commands available",
"{{count}} edits": "{{count}} edits",
"{{count}} major": "{{count}} major",
"{{count}} result available_one": "1 result available",
"{{count}} result available_other": "{{count}} results available",
"{{count}} result found_one": "{{count}} result found",
@@ -13,16 +13,8 @@
"Add space members": "Добавить участников пространства",
"Add to favorites": "Добавить в избранное",
"Admin": "Администратор",
"API key copied to clipboard": "API ключ скопирован в буфер обмена",
"API key created": "API ключ создан",
"API key revoked": "API ключ отозван",
"Confirm your password": "Подтвердите пароль",
"Copy API key": "Скопировать API ключ",
"Copy to clipboard": "Скопировать в буфер обмена",
"Copy {{name}}": "Скопировать {{name}}",
"Enter your password to copy the API key \"{{name}}\" to your clipboard.": "Введите пароль, чтобы скопировать API ключ «{{name}}» в буфер обмена.",
"Failed to copy API key": "Не удалось скопировать API ключ",
"Incorrect password": "Неверный пароль",
"API keys across the workspace.": "API ключи всего рабочего пространства.",
"Are you sure you want to delete this group? Members will lose access to resources this group has access to.": "Вы уверены, что хотите удалить эту группу? Участники потеряют доступ к материалам, к которым у этой группы есть доступ.",
"Are you sure you want to delete this page?": "Вы уверены, что хотите удалить эту страницу?",
@@ -56,6 +48,7 @@
"Create page": "Создать страницу",
"Create space": "Создать пространство",
"Create workspace": "Создать рабочую область",
"Critical": "Критично",
"Current password": "Текущий пароль",
"Dark": "Темная",
"Date": "Дата",
@@ -135,6 +128,7 @@
"Link copied": "Ссылка скопирована",
"Login": "Войти",
"Logout": "Выйти",
"Major": "Существенно",
"Manage Group": "Управление группой",
"Manage members": "Управление участниками",
"member": "участник",
@@ -469,6 +463,8 @@
"{{count}} Columns": "{count, plural, one{# столбец} few{# столбца} many{# столбцов} other{# столбца}}",
"{{count}} command available_one": "Доступна 1 команда",
"{{count}} command available_other": "Доступно {{count}} команд",
"{{count}} edits": "{count, plural, one{# правка} few{# правки} many{# правок} other{# правки}}",
"{{count}} major": "{count, plural, one{# существенная} few{# существенных} many{# существенных} other{# существенных}}",
"{{count}} result available_one": "Доступен 1 результат",
"{{count}} result available_other": "Доступно {{count}} результатов",
"{{count}} result found_one": "Найден {{count}} результат",
@@ -20,14 +20,12 @@ vi.mock("@/features/api-key/services/api-key-service", () => ({
getApiKeys: vi.fn(),
createApiKey: vi.fn(),
revokeApiKey: vi.fn(),
revealApiKey: vi.fn(),
}));
import {
getApiKeys,
createApiKey,
revokeApiKey,
revealApiKey,
} from "@/features/api-key/services/api-key-service";
import ApiKeysManager from "./api-keys-manager";
@@ -183,9 +181,9 @@ describe("ApiKeysManager — revoke (acceptance #4)", () => {
});
});
describe("ApiKeysManager — create no longer shows the token (copy replaces show-once)", () => {
it("create closes the modal + toasts without ever rendering the token", async () => {
const SECRET = "gm_secret-created-value";
describe("ApiKeysManager — show-once token (acceptance #1 & #2)", () => {
it("shows the token once, then discards it from the UI, localStorage and query cache", async () => {
const SECRET = "gm_secret-token-value-xyz";
vi.mocked(getApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({
token: SECRET,
@@ -200,6 +198,7 @@ describe("ApiKeysManager — create no longer shows the token (copy replaces sho
const { queryClient } = renderManager(UserRole.MEMBER);
await screen.findByText("No API keys yet");
// Open create modal (use the header CTA), fill the name, submit.
fireEvent.click(
screen.getAllByRole("button", { name: "Create API key" })[0],
);
@@ -207,59 +206,22 @@ describe("ApiKeysManager — create no longer shows the token (copy replaces sho
fireEvent.change(nameInput, { target: { value: "My key" } });
fireEvent.click(screen.getByRole("button", { name: "Create" }));
await waitFor(() => expect(createApiKey).toHaveBeenCalled());
// The token is never rendered (no more show-once modal).
expect(screen.queryByTestId("api-key-token")).toBeNull();
expect(screen.queryByText(SECRET)).toBeNull();
// Token is shown exactly once in the show-once modal.
const tokenEl = await screen.findByTestId("api-key-token");
expect(tokenEl.textContent).toBe(SECRET);
// The created token never lands in localStorage or the react-query caches.
expect(storageDump()).not.toContain(SECRET);
const cacheDump = JSON.stringify(
queryClient.getQueryCache().getAll().map((q) => q.state.data),
);
expect(cacheDump).not.toContain(SECRET);
const mutationDump = JSON.stringify(
queryClient.getMutationCache().getAll().map((m) => m.state.data),
);
expect(mutationDump).not.toContain(SECRET);
});
});
describe("ApiKeysManager — copy under step-up (acceptance #9)", () => {
it("copies a re-minted token to the clipboard and leaks it nowhere else", async () => {
const SECRET = "gm_revealed-token-value-xyz";
const writeText = vi.fn().mockResolvedValue(undefined);
// jsdom has no clipboard; install a stub the copy helper will use.
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
vi.mocked(getApiKeys).mockResolvedValue([makeKey({ id: "k1", name: "CI token" })]);
vi.mocked(revealApiKey).mockResolvedValue(SECRET);
const { queryClient } = renderManager(UserRole.MEMBER);
await screen.findByText("CI token");
// Click the per-row Copy action → password step-up modal.
fireEvent.click(screen.getByLabelText("Copy CI token"));
const pwInput = await screen.findByLabelText("Password");
fireEvent.change(pwInput, { target: { value: "hunter2" } });
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
// The reveal request carried the id + password; the token was written to the
// clipboard and NOWHERE else.
// Close the modal → token discarded from the DOM.
fireEvent.click(screen.getByRole("button", { name: "Done" }));
await waitFor(() =>
expect(revealApiKey).toHaveBeenCalledWith({
id: "k1",
password: "hunter2",
}),
expect(screen.queryByTestId("api-key-token")).toBeNull(),
);
await waitFor(() => expect(writeText).toHaveBeenCalledWith(SECRET));
// The revealed secret is never in the DOM, localStorage, or either cache.
expect(screen.queryByText(SECRET)).toBeNull();
expect(storageDump()).not.toContain(SECRET);
// Acceptance #2: the secret is nowhere in localStorage or the react-query
// caches (query cache never carried it; the mutation copy was reset()).
// Non-vacuous: currentUser IS in storage, so the dump is exercised.
const dump = storageDump();
expect(dump).toContain("currentUser");
expect(dump).not.toContain(SECRET);
const cacheDump = JSON.stringify(
queryClient.getQueryCache().getAll().map((q) => q.state.data),
);
@@ -269,28 +231,4 @@ describe("ApiKeysManager — copy under step-up (acceptance #9)", () => {
);
expect(mutationDump).not.toContain(SECRET);
});
it("a wrong password keeps the modal open and does not copy", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
vi.mocked(getApiKeys).mockResolvedValue([makeKey({ id: "k1", name: "CI token" })]);
// Server returns a 401 for a wrong step-up password.
vi.mocked(revealApiKey).mockRejectedValue({ response: { status: 401 } });
renderManager(UserRole.MEMBER);
await screen.findByText("CI token");
fireEvent.click(screen.getByLabelText("Copy CI token"));
const pwInput = await screen.findByLabelText("Password");
fireEvent.change(pwInput, { target: { value: "wrong" } });
fireEvent.click(screen.getByRole("button", { name: "Copy to clipboard" }));
await waitFor(() => expect(revealApiKey).toHaveBeenCalled());
// Nothing copied; the password field is still on screen for a retry.
expect(writeText).not.toHaveBeenCalled();
expect(await screen.findByLabelText("Password")).toBeDefined();
});
});
@@ -13,26 +13,27 @@ import {
} from "@mantine/core";
import { modals } from "@mantine/modals";
import { notifications } from "@mantine/notifications";
import { IconAlertTriangle, IconCopy, IconKey, IconTrash } from "@tabler/icons-react";
import { IconAlertTriangle, IconKey, IconTrash } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import useUserRole from "@/hooks/use-user-role.tsx";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { timeAgo } from "@/lib/time";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import {
useApiKeysQuery,
useCreateApiKeyMutation,
useRevealApiKeyMutation,
useRevokeApiKeyMutation,
} from "@/features/api-key/queries/api-key-query";
import { IApiKey } from "@/features/api-key/types/api-key.types";
import {
IApiKey,
ICreateApiKeyResponse,
} from "@/features/api-key/types/api-key.types";
import {
isExpired,
isExpiringSoon,
lastUsedBucket,
} from "@/features/api-key/utils";
import { CreateApiKeyModal } from "./create-api-key-modal";
import { RevealKeyModal } from "./reveal-key-modal";
import { ShowTokenModal } from "./show-token-modal";
export default function ApiKeysManager() {
const { t } = useTranslation();
@@ -45,27 +46,27 @@ export default function ApiKeysManager() {
const { data: keys, isLoading, isError } = useApiKeysQuery();
const createMutation = useCreateApiKeyMutation();
const revokeMutation = useRevokeApiKeyMutation();
const revealMutation = useRevealApiKeyMutation();
const [createOpened, setCreateOpened] = useState(false);
// SECURITY: only the key METADATA is held here (for the modal title) — never a
// token. The token is copied straight to the clipboard in handleCopy and is
// never stored in state, the query cache or localStorage.
const [revealTarget, setRevealTarget] = useState<IApiKey | null>(null);
// SECURITY: the show-once token lives ONLY here, in this component's local
// state. It is never written to localStorage, the query cache or a log. Closing
// the modal sets it back to null (handleCloseToken) — discarded forever.
const [createdKey, setCreatedKey] = useState<ICreateApiKeyResponse | null>(
null,
);
const handleCreate = async (values: {
name: string;
expiresAt: string | null;
}): Promise<boolean> => {
try {
await createMutation.mutateAsync(values);
// The create response carries a token, but it is now retrievable any time
// via the per-row Copy action (reveal), so we DISCARD it here: purge
// react-query's copy immediately and never move it into state. The user
// copies the key from the list via a password step-up.
createMutation.reset();
const res = await createMutation.mutateAsync(values);
setCreateOpened(false);
notifications.show({ message: t("API key created") });
// Move the token into local state, then immediately purge react-query's
// own copy of the mutation result so the secret does not linger in the
// mutation cache.
setCreatedKey(res);
createMutation.reset();
return true;
} catch {
notifications.show({
@@ -76,36 +77,9 @@ export default function ApiKeysManager() {
}
};
// Password step-up accepted -> re-mint + copy. The token exists only as a local
// `const` for the single clipboard write, then react-query's copy is reset().
// It never enters component state, the query cache or localStorage.
const handleCopy = async (password: string): Promise<boolean> => {
const target = revealTarget;
if (!target) return false;
try {
const token = await revealMutation.mutateAsync({
id: target.id,
password,
});
await copyToClipboard(token);
revealMutation.reset();
notifications.show({ message: t("API key copied to clipboard") });
return true;
} catch (err) {
revealMutation.reset();
// 401 = wrong password (keep the modal open to retry); anything else is a
// uniform failure (the server does not distinguish key states).
const status = (err as { response?: { status?: number } })?.response
?.status;
notifications.show({
message:
status === 401
? t("Incorrect password")
: t("Failed to copy API key"),
color: "red",
});
return false;
}
const handleCloseToken = () => {
// Token discarded — the only copy the UI ever held is dropped here.
setCreatedKey(null);
};
const openRevokeModal = (key: IApiKey) =>
@@ -212,28 +186,16 @@ export default function ApiKeysManager() {
</Table.Td>
)}
<Table.Td style={{ textAlign: "right" }}>
<Group gap={4} justify="flex-end" wrap="nowrap">
<Tooltip label={t("Copy API key")} withArrow>
<ActionIcon
variant="subtle"
aria-label={t("Copy {{name}}", { name: key.name })}
disabled={expired}
onClick={() => setRevealTarget(key)}
>
<IconCopy size={16} />
</ActionIcon>
</Tooltip>
<Tooltip label={t("Revoke")} withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke {{name}}", { name: key.name })}
onClick={() => openRevokeModal(key)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Group>
<Tooltip label={t("Revoke")} withArrow>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Revoke {{name}}", { name: key.name })}
onClick={() => openRevokeModal(key)}
>
<IconTrash size={16} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
);
@@ -296,13 +258,7 @@ export default function ApiKeysManager() {
loading={createMutation.isPending}
/>
<RevealKeyModal
keyName={revealTarget?.name ?? null}
opened={revealTarget !== null}
onClose={() => setRevealTarget(null)}
onConfirm={handleCopy}
loading={revealMutation.isPending}
/>
<ShowTokenModal created={createdKey} onClose={handleCloseToken} />
</>
);
}
@@ -1,85 +0,0 @@
import { useState } from "react";
import {
Button,
Group,
Modal,
PasswordInput,
Stack,
Text,
} from "@mantine/core";
import { useTranslation } from "react-i18next";
interface Props {
// The key being copied (metadata only — never its token). `null` closes.
keyName: string | null;
opened: boolean;
onClose: () => void;
// Step-up: resolves true when the password was accepted, the token was copied
// to the clipboard and the modal should close; false to keep it open (wrong
// password) so the user can retry. The password is passed straight through and
// never persisted here.
onConfirm: (password: string) => Promise<boolean>;
loading?: boolean;
}
// Password step-up before a copyable key is re-minted + copied. This modal only
// ever holds the PASSWORD (transient, cleared on close) — never the revealed
// token, which the parent writes straight to the clipboard.
export function RevealKeyModal({
keyName,
opened,
onClose,
onConfirm,
loading,
}: Props) {
const { t } = useTranslation();
const [password, setPassword] = useState("");
const close = () => {
setPassword("");
onClose();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (password.length === 0) return;
const ok = await onConfirm(password);
if (ok) close();
// On failure keep the modal open; clear the field so a retry starts clean.
else setPassword("");
};
return (
<Modal
opened={opened}
onClose={close}
title={t("Confirm your password")}
centered
>
<form onSubmit={handleSubmit}>
<Stack gap="sm">
<Text size="sm" c="dimmed">
{t(
'Enter your password to copy the API key "{{name}}" to your clipboard.',
{ name: keyName ?? "" },
)}
</Text>
<PasswordInput
label={t("Password")}
data-autofocus
value={password}
onChange={(e) => setPassword(e.currentTarget.value)}
/>
<Group justify="flex-end" mt="xs">
<Button variant="default" onClick={close} type="button">
{t("Cancel")}
</Button>
<Button type="submit" loading={loading} disabled={password.length === 0}>
{t("Copy to clipboard")}
</Button>
</Group>
</Stack>
</form>
</Modal>
);
}
@@ -0,0 +1,107 @@
import {
Alert,
Button,
Code,
Group,
Modal,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconCheck, IconCopy } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { CopyButton } from "@/components/common/copy-button";
import { formatLocalized, useDateFnsLocale } from "@/lib/date-locale";
import { ICreateApiKeyResponse } from "@/features/api-key/types/api-key.types";
interface Props {
// The freshly-created key incl. its token. Owned by the parent; this modal
// only renders it and never copies it into its own persistent state.
created: ICreateApiKeyResponse | null;
// Closing MUST discard the token in the parent (set the `created` prop back to
// null) — the token is shown exactly once.
onClose: () => void;
}
export function ShowTokenModal({ created, onClose }: Props) {
const { t } = useTranslation();
const locale = useDateFnsLocale();
const expiresAt = created?.apiKey.expiresAt ?? null;
return (
<Modal
opened={created !== null}
onClose={onClose}
title={t("API key created")}
centered
// No dismiss-on-outside-click: the token is irretrievable, so closing is a
// deliberate act (the user confirms they have saved it).
closeOnClickOutside={false}
>
{created && (
<Stack gap="sm">
<Alert
color="orange"
icon={<IconAlertTriangle size={18} />}
variant="light"
>
{t(
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
)}
</Alert>
<div>
<Text size="sm" c="dimmed" mb={4}>
{t("Token")}
</Text>
<Group gap="xs" wrap="nowrap" align="flex-start">
<Code
block
data-testid="api-key-token"
style={{ flex: 1, wordBreak: "break-all" }}
>
{created.token}
</Code>
<CopyButton value={created.token}>
{({ copied, copy }) => (
<Button
variant="light"
size="xs"
color={copied ? "teal" : "blue"}
leftSection={
copied ? (
<IconCheck size={16} />
) : (
<IconCopy size={16} />
)
}
onClick={copy}
>
{copied ? t("Copied") : t("Copy")}
</Button>
)}
</CopyButton>
</Group>
</div>
<Text size="sm" c="dimmed">
{expiresAt
? t("Expires {{date}}", {
date: formatLocalized(
new Date(expiresAt),
"MMM dd, yyyy",
"PP",
locale,
),
})
: t("This key never expires")}
</Text>
<Group justify="flex-end" mt="xs">
<Button onClick={onClose}>{t("Done")}</Button>
</Group>
</Stack>
)}
</Modal>
);
}
@@ -9,14 +9,12 @@ import { useTranslation } from "react-i18next";
import {
createApiKey,
getApiKeys,
revealApiKey,
revokeApiKey,
} from "@/features/api-key/services/api-key-service";
import {
IApiKey,
ICreateApiKey,
ICreateApiKeyResponse,
IRevealApiKey,
} from "@/features/api-key/types/api-key.types";
export const API_KEYS_QUERY_KEY = ["api-keys"];
@@ -49,23 +47,6 @@ export function useCreateApiKeyMutation() {
});
}
/**
* Reveal (copy) mutation.
*
* SECURITY (mirrors useCreateApiKeyMutation): the resolved value is the raw
* token. This hook deliberately stashes it NOWHERE — the caller reads it from
* `mutateAsync`, writes it straight to the clipboard, then calls
* `mutation.reset()` to purge react-query's own copy. `gcTime: 0` is the second
* belt so nothing lingers in the mutation cache after the observer unmounts.
* There is no `onSuccess` list invalidation: reveal does not change the list.
*/
export function useRevealApiKeyMutation() {
return useMutation<string, Error, IRevealApiKey>({
mutationFn: (data) => revealApiKey(data),
gcTime: 0,
});
}
export function useRevokeApiKeyMutation() {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -15,7 +15,6 @@ vi.mock("@/lib/api-client", () => ({
import {
createApiKey,
getApiKeys,
revealApiKey,
} from "@/features/api-key/services/api-key-service";
beforeEach(() => {
@@ -76,23 +75,4 @@ describe("api-key-service response-contract unwrap", () => {
expect(result).toHaveLength(2);
expect(post).toHaveBeenCalledWith("/api-keys/list");
});
it("revealApiKey unwraps the envelope to the bare token string", async () => {
const SECRET = "gm_revealed-token";
post.mockResolvedValue({
data: { token: SECRET },
success: true,
status: 200,
});
const result = await revealApiKey({ id: "key-1", password: "pw" });
// The service returns ONLY the token string (not the { token } wrapper), so
// the caller can copy it straight to the clipboard.
expect(result).toBe(SECRET);
expect(post).toHaveBeenCalledWith("/api-keys/reveal", {
id: "key-1",
password: "pw",
});
});
});
@@ -3,7 +3,6 @@ import {
IApiKey,
ICreateApiKey,
ICreateApiKeyResponse,
IRevealApiKey,
} from "@/features/api-key/types/api-key.types";
// Mint a new key. The response carries the token ONCE — the caller must move it
@@ -29,13 +28,3 @@ export async function getApiKeys(): Promise<IApiKey[]> {
export async function revokeApiKey(id: string): Promise<void> {
await api.post("/api-keys/revoke", { id });
}
// Reveal (re-mint) an existing key's token under a password step-up. Returns the
// bare token string — the SECURITY contract (mirrors create): the caller must
// write it straight to the clipboard and never stash it in state, the query
// cache or localStorage. See useRevealApiKeyMutation (gcTime: 0 + reset-after-
// read) and api-keys-manager.tsx `handleCopy`.
export async function revealApiKey(data: IRevealApiKey): Promise<string> {
const res = await api.post<{ token: string }>("/api-keys/reveal", data);
return (res.data as { token: string }).token;
}
@@ -39,20 +39,10 @@ export interface ICreatedApiKey {
createdAt: string;
}
// Response of `POST /api/api-keys/create`. `token` is returned on create; it is
// ALSO retrievable later via reveal (deterministic re-mint under a step-up), so
// it is no longer "show once". It must never be cached, persisted or logged
// the create flow discards it (the user copies via the per-row Copy action).
// Response of `POST /api/api-keys/create`. `token` is the ONLY time the secret
// is ever returned — it must live only in the show-once modal's local state and
// must never be cached, persisted or logged.
export interface ICreateApiKeyResponse {
token: string;
apiKey: ICreatedApiKey;
}
// Payload for `POST /api/api-keys/reveal`: the key id + the caller's current
// password (step-up). The response is `{ token }` — a re-minted, byte-identical
// copy of the key's token, which must be written straight to the clipboard and
// never held in state, cache or storage.
export interface IRevealApiKey {
id: string;
password: string;
}
@@ -0,0 +1,284 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import { createInstance } from "i18next";
import { initReactI18next, I18nextProvider } from "react-i18next";
import { IComment } from "@/features/comment/types/comment.types";
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
// The suggestion mutations reach react-query/network — stub them so the card
// renders in isolation. We assert the Apply/Dismiss gating and that the click
// hands the {commentId, pageId} pair to the existing mutation unchanged.
const applyMutateAsync = vi.fn();
const dismissMutateAsync = vi.fn();
vi.mock("@/features/comment/queries/comment-query", () => ({
useApplySuggestionMutation: () => ({
mutateAsync: applyMutateAsync,
isPending: false,
}),
useDismissSuggestionMutation: () => ({
mutateAsync: dismissMutateAsync,
isPending: false,
}),
}));
// CommentContentView -> mention-view -> page-query/share-query pull in the app
// entry (createRoot) as a side effect; stub the queries so the card renders in
// isolation.
vi.mock("@/features/page/queries/page-query.ts", () => ({
usePageQuery: () => ({ data: undefined, isLoading: false, isError: false }),
}));
vi.mock("@/features/share/queries/share-query.ts", () => ({
useSharePageQuery: () => ({ data: undefined }),
}));
// space-query.ts -> main.tsx (createRoot) is a module side effect reached via the
// mention view; stub it so importing the card is side-effect free.
vi.mock("@/features/space/queries/space-query.ts", () => ({
useSpaceQuery: () => ({ data: undefined }),
useGetSpaceBySlugQuery: () => ({ data: undefined }),
}));
import AgentEditCard, { RunHeader } from "./agent-edit-card";
const body = (text: string) =>
JSON.stringify({
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
});
const edit = (over?: Partial<IComment>): IComment =>
({
id: "c-1",
content: body("[Существенно] tighten the wording"),
creatorId: "user-1",
pageId: "page-1",
workspaceId: "ws-1",
createdAt: new Date(),
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Corrector", emoji: "✏️", avatarUrl: null },
launcher: { name: "Alice", avatarUrl: null },
creator: { id: "user-1", name: "Corrector", avatarUrl: null } as any,
selection: "old wording here",
suggestedText: "new wording here",
...over,
}) as IComment;
function renderCard(
comment: IComment,
canEdit = true,
canComment = true,
userSpaceRole?: string,
) {
return render(
<MantineProvider>
<AgentEditCard
comment={comment}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</MantineProvider>,
);
}
describe("AgentEditCard — suggested edit diff + Apply (#315)", () => {
it("renders the было→стало diff and an Apply button when canEdit, not applied/resolved", () => {
const { container } = renderCard(edit(), true);
// Both diff lines are present (old struck-through, new added).
expect(container.textContent).toContain("old wording here");
expect(container.textContent).toContain("new wording here");
// Diff line signs (aria-hidden) present for a replacement.
expect(container.textContent).toContain("−");
expect(container.textContent).toContain("+");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
});
it("hides Apply when canEdit is false (still shows the diff)", () => {
const { container } = renderCard(edit(), false);
expect(container.textContent).toContain("new wording here");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Apply once the thread is resolved", () => {
renderCard(edit({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Apply once suggestionAppliedAt is set", () => {
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows the Applied badge for an applied suggestion, not a pending one (F1)", () => {
// Pending: no Applied badge.
const { unmount } = renderCard(edit(), true);
expect(screen.queryByText("Applied")).toBeNull();
unmount();
// Applied (kept alive by replies -> resolved, #329): the badge is restored.
renderCard(edit({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("calls the apply mutation with {commentId, pageId} on click", () => {
applyMutateAsync.mockClear();
renderCard(edit(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("a pure insertion (empty selection) hides the removed line", () => {
const { container } = renderCard(
edit({ selection: "", suggestedText: "added text" }),
true,
);
// No "−" del sign — nothing was removed.
expect(container.textContent).not.toContain("−");
expect(container.textContent).toContain("+");
});
it("a pure deletion (empty suggestedText) hides the added line", () => {
const { container } = renderCard(
edit({ selection: "removed text", suggestedText: "" }),
true,
);
expect(container.textContent).not.toContain("+");
expect(container.textContent).toContain("−");
});
});
describe("AgentEditCard — Dismiss gate (#329/#338)", () => {
it("shows Dismiss alongside Apply for an admin who can edit/comment", () => {
renderCard(edit(), true, true, "admin");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
renderCard(edit(), false, true, "admin");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("hides Dismiss for a non-owner non-admin (mirrors server 403, #338 F5)", () => {
renderCard(edit(), false, true, "member");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss when the viewer cannot comment", () => {
renderCard(edit(), false, false, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("calls the dismiss mutation with {commentId, pageId} on click", () => {
dismissMutateAsync.mockClear();
renderCard(edit(), true, true, "admin");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(dismissMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
});
describe("AgentEditCard — provenance", () => {
it("renders the agent avatar stack (provenance) for the edit author (#300)", () => {
renderCard(edit(), true);
// The agent role name is shown by the provenance stack.
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
});
// The owner-or-admin gate uses the currentUser atom; clear localStorage so a
// previous test's seed never leaks into the non-owner assertions above.
beforeEach(() => localStorage.clear());
afterEach(() => localStorage.clear());
});
// The RunHeader's "N edits · M major" is built with interpolated t() keys; the
// default (uninitialized) react-i18next t returns the key verbatim WITHOUT
// interpolating, so a numeric assertion needs a real, initialised i18n instance.
// This isolated instance carries just the two count keys with escapeValue off so
// "{{count}}" is substituted and the rendered numbers are assertable.
const headerI18n = createInstance();
headerI18n.use(initReactI18next).init({
lng: "en",
fallbackLng: "en",
resources: {
en: {
translation: {
"{{count}} edits": "{{count}} edits",
"{{count}} major": "{{count}} major",
},
},
},
interpolation: { escapeValue: false },
});
const runComment = (id: string, tag: string): IComment =>
edit({
id,
content: body(`${tag} some rationale`),
});
function renderRunHeader(comments: IComment[]) {
return render(
<I18nextProvider i18n={headerI18n}>
<MantineProvider>
<RunHeader comments={comments} />
</MantineProvider>
</I18nextProvider>,
);
}
describe("RunHeader — agent-run series header (F3)", () => {
// 5 edits: 2 critical + 1 major + 1 minor + 1 unknown(verdict) => 3 "major".
const series = () => [
runComment("e1", "[Критично]"),
runComment("e2", "[Критично]"),
runComment("e3", "[Существенно]"),
runComment("e4", "[Незначительно]"),
runComment("e5", "[Неверно]"),
];
it("shows the total edit count", () => {
renderRunHeader(series());
expect(screen.getByText(/5 edits/)).toBeDefined();
});
it("counts ONLY major+critical as major (not minor/unknown)", () => {
renderRunHeader(series());
// 2 critical + 1 major = 3; minor and the [Неверно] verdict are excluded.
expect(screen.getByText(/3 major/)).toBeDefined();
// Non-vacuous: the wrong tally (counting all 5, or 4) must NOT appear.
expect(screen.queryByText(/5 major/)).toBeNull();
expect(screen.queryByText(/4 major/)).toBeNull();
});
it("omits the major segment entirely when there are no significant edits", () => {
renderRunHeader([
runComment("e1", "[Незначительно]"),
runComment("e2", "[Неверно]"),
]);
expect(screen.getByText(/2 edits/)).toBeDefined();
expect(screen.queryByText(/major/)).toBeNull();
});
it("renders the provenance line (agent role name)", () => {
renderRunHeader(series());
expect(screen.getAllByText("Corrector").length).toBeGreaterThan(0);
});
it("renders NO 'Accept all' control (rejected by product)", () => {
renderRunHeader(series());
expect(screen.queryByText(/accept all/i)).toBeNull();
expect(
screen.queryByRole("button", { name: /accept all/i }),
).toBeNull();
});
});
@@ -0,0 +1,418 @@
import React, { useMemo } from "react";
import {
Badge,
Box,
Button,
Group,
Stack,
Text,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
import { useAtom } from "jotai";
import { useTranslation } from "react-i18next";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import CommentContentView from "@/features/comment/components/comment-content-view";
import { IComment } from "@/features/comment/types/comment.types";
import {
canShowApply,
canShowDismiss,
computeSuggestionDiff,
Segment,
} from "@/features/comment/utils/suggestion";
import { commentContentToText } from "@/features/comment/utils/comment-content-to-text";
import {
isSignificant,
parseSeverity,
Severity,
} from "@/features/comment/utils/severity";
import {
useApplySuggestionMutation,
useDismissSuggestionMutation,
} from "@/features/comment/queries/comment-query";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTimeAgo } from "@/hooks/use-time-ago";
// Scroll the document to this comment's inline mark and flash it — the same
// anchor navigation the old panel used (handleCommentClick). Used both by a card
// click and by an explicit "Go to text" affordance.
function scrollToCommentMark(commentId: string) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${commentId}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("comment-highlight");
setTimeout(() => el.classList.remove("comment-highlight"), 3000);
}
}
// Make otherwise-invisible edited whitespace legible inside a highlighted diff
// fragment (a pure-space insertion/deletion would otherwise look like nothing
// changed).
function visibleWhitespace(s: string): string {
return s.replace(/ /g, "␣").replace(/\t/g, "⇥");
}
// One line of the intraline diff. `kind==="del"` is the old ("было") line drawn
// red with a strike-through; `kind==="ins"` is the new ("стало") line drawn
// green. Only the `changed` segments carry the emphasised mark background — the
// common segments read as plain context (git/GitHub-style, #331).
function DiffLine({
segments,
kind,
}: {
segments: Segment[];
kind: "del" | "ins";
}) {
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const dark = colorScheme === "dark";
const isDel = kind === "del";
const sign = isDel ? "−" : "+";
const signColor = isDel
? theme.colors.red[dark ? 5 : 6]
: theme.colors.green[dark ? 5 : 6];
const baseColor = isDel
? dark
? theme.colors.gray[5]
: theme.colors.gray[6]
: dark
? theme.colors.gray[1]
: theme.colors.dark[9];
const markBg = isDel
? dark
? "rgba(224,49,49,.22)"
: "#ffe3e3"
: dark
? "rgba(47,158,68,.22)"
: "#d3f9d8";
const markFg = isDel
? dark
? theme.colors.red[3]
: theme.colors.red[8]
: dark
? theme.colors.green[3]
: theme.colors.green[9];
return (
<Group gap={7} wrap="nowrap" align="flex-start">
<Text
ff="monospace"
fw={600}
fz={12}
c={signColor}
w={11}
ta="center"
aria-hidden
style={{ flex: "none", lineHeight: 1.5 }}
>
{sign}
</Text>
<Text fz={13.5} c={baseColor} style={{ lineHeight: 1.45 }}>
{segments.map((seg, i) =>
seg.changed ? (
<Box
key={i}
component="mark"
px={3}
fw={600}
style={{
background: markBg,
color: markFg,
borderRadius: 3,
textDecoration: isDel ? "line-through" : "none",
}}
>
{visibleWhitespace(seg.text)}
</Box>
) : (
<Box
key={i}
component="span"
style={{ textDecoration: isDel ? "line-through" : "none" }}
>
{seg.text}
</Box>
),
)}
</Text>
</Group>
);
}
// The "было → стало" block. A pure insertion (empty `before`) hides the old
// line; a pure deletion (empty `after`) hides the new line.
function DiffBlock({ before, after }: { before: Segment[]; after: Segment[] }) {
const pureInsert = before.length === 0;
const pureDelete = after.length === 0;
return (
<Stack gap={1}>
{!pureInsert && <DiffLine segments={before} kind="del" />}
{!pureDelete && <DiffLine segments={after} kind="ins" />}
</Stack>
);
}
const SEV_DOT: Record<Severity, { color: string; shade: number }> = {
critical: { color: "red", shade: 6 },
major: { color: "orange", shade: 6 },
minor: { color: "gray", shade: 5 },
// A neutral, deliberately faint dot — NOT the minor grey — so an untagged or
// verdict-only edit never reads as a graded severity.
unknown: { color: "gray", shade: 3 },
};
function SeverityDot({ severity }: { severity: Severity }) {
return (
<Box
w={8}
h={8}
aria-hidden
style={(t) => ({
flex: "none",
borderRadius: "50%",
background: t.colors[SEV_DOT[severity].color][SEV_DOT[severity].shade],
})}
/>
);
}
interface AgentEditCardProps {
comment: IComment;
canComment: boolean;
canEdit?: boolean;
userSpaceRole?: string;
// Whether to render the per-card agent avatar + timestamp. A card inside a
// collapsed run omits it (the RunHeader carries the one provenance line);
// a standalone card shows it (#300 provenance must be visible on the card).
showProvenance?: boolean;
}
// Type A — the diff-first agent suggested-edit card. It carries NO TipTap editor
// (a perf win vs. the old row, #340); the body renders through the static
// CommentContentView. Apply/Dismiss reuse the existing mutations and gates
// verbatim — the reskin changes presentation only, not the 409/404/400 toast
// semantics or the authz gating.
function AgentEditCard({
comment,
canComment,
canEdit,
userSpaceRole,
showProvenance = true,
}: AgentEditCardProps) {
const { t } = useTranslation();
const [currentUser] = useAtom(currentUserAtom);
const applySuggestionMutation = useApplySuggestionMutation();
const dismissSuggestionMutation = useDismissSuggestionMutation();
const createdAtAgo = useTimeAgo(comment.createdAt);
const diff = useMemo(
() =>
computeSuggestionDiff(comment.selection ?? "", comment.suggestedText ?? ""),
[comment.selection, comment.suggestedText],
);
const severity = useMemo(
() => parseSeverity(commentContentToText(comment.content)),
[comment.content],
);
// Owner-or-space-admin gate (#338), mirrored from the old row so we never
// render a Dismiss the server would 403.
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
const isApplied = comment.suggestionAppliedAt != null;
const showApply = canShowApply(comment, canEdit);
const showDismiss = canShowDismiss(comment, canComment, isOwnerOrAdmin);
const pending =
applySuggestionMutation.isPending || dismissSuggestionMutation.isPending;
const handleApply = async () => {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors (incl. 409 "text changed") surface via the mutation's onError.
console.error("Failed to apply suggestion:", error);
}
};
const handleDismiss = async () => {
try {
await dismissSuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Idempotent 404 is reconciled to success in the mutation's onError.
console.error("Failed to dismiss suggestion:", error);
}
};
const sevLabel =
severity === "critical"
? t("Critical")
: severity === "major"
? t("Major")
: null;
return (
<Box
p="10px 12px"
role="button"
tabIndex={0}
aria-label={t("Jump to comment selection")}
onClick={() => scrollToCommentMark(comment.id)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
scrollToCommentMark(comment.id);
}
}}
style={{ cursor: "pointer" }}
>
<Stack gap={8}>
{showProvenance && comment.agent && (
<Group
gap={8}
wrap="nowrap"
justify="space-between"
onClick={(e) => e.stopPropagation()}
>
<AgentAvatarStack
agent={comment.agent}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
/>
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
{createdAtAgo}
</Text>
</Group>
)}
<DiffBlock before={diff.old} after={diff.new} />
{/* Agent rationale — rendered through the static ProseMirror view, not
restructured into a flat string. */}
<Box fz="xs" c="dimmed">
<CommentContentView content={comment.content} />
</Box>
<Group gap={8} wrap="nowrap" onClick={(e) => e.stopPropagation()}>
<SeverityDot severity={severity} />
{sevLabel && (
<Text
fz={10}
fw={600}
tt="uppercase"
c="dimmed"
style={{ letterSpacing: ".06em", flex: 1 }}
>
{sevLabel}
</Text>
)}
<Box style={{ flex: 1 }} />
{/* Applied state (#315): a suggestion that was applied but kept alive by
its replies (so #329 resolved instead of hard-deleting it) still
shows it was applied — the badge the pre-redesign card carried. A
childless applied suggestion is gone from the list entirely, so this
only renders in the Resolved tab. */}
{isApplied && (
<Badge
size="sm"
color="green"
variant="light"
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
)}
{showDismiss && (
<Button
size="compact-sm"
variant="default"
color="gray"
loading={dismissSuggestionMutation.isPending}
disabled={pending}
onClick={handleDismiss}
>
{t("Dismiss")}
</Button>
)}
{showApply && (
<Button
size="compact-sm"
color="green"
loading={applySuggestionMutation.isPending}
disabled={pending}
onClick={handleApply}
>
{t("Apply")}
</Button>
)}
</Group>
</Stack>
</Box>
);
}
// Series header for a collapsed agent run: ONE provenance line for N edits,
// with a "N edits · M major" tally. There is intentionally NO "Accept all"
// button / progress / Stop (rejected by product) and NO "K applied" counter
// (uncomputable after the #329 hard-delete). Purely visual.
export function RunHeader({ comments }: { comments: IComment[] }) {
const { t } = useTranslation();
const head = comments[0];
const createdAtAgo = useTimeAgo(head?.createdAt);
const majors = useMemo(
() =>
comments.filter((c) =>
isSignificant(parseSeverity(commentContentToText(c.content))),
).length,
[comments],
);
if (!head) return null;
return (
<Box
p="11px 13px"
style={{ borderBottom: "1px solid var(--mantine-color-default-border)" }}
>
<Group gap={10} wrap="nowrap" justify="space-between">
{head.agent ? (
<AgentAvatarStack
agent={head.agent}
launcher={head.launcher}
aiChatId={head.aiChatId}
/>
) : (
<Text size="sm" fw={600}>
{head.creator?.name}
</Text>
)}
<Text size="xs" c="dimmed" style={{ flex: "none" }}>
{createdAtAgo}
</Text>
</Group>
<Text fz={11.5} c="dimmed" mt={4}>
{t("{{count}} edits", { count: comments.length })}
{majors > 0 && (
<>
{" · "}
<Text span c="orange.7" fw={600} inherit>
{t("{{count}} major", { count: majors })}
</Text>
</>
)}
</Text>
</Box>
);
}
export default React.memo(AgentEditCard);
@@ -156,125 +156,6 @@ describe("CommentListItem — agent avatar stack", () => {
// only guards the insertion gate (agent → stack, user → no stack).
});
describe("CommentListItem — suggested edit (#315)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
it("renders the было→стало diff and an Apply button when canEdit and not applied/resolved", () => {
const { container } = renderItem(suggestion(), true);
// Old text appears as the selection quote (a single unsplit Text node).
expect(screen.getAllByText("old wording here").length).toBeGreaterThan(0);
// The new line is now rendered as per-fragment spans (intraline diff, #331),
// so it is no longer a single text node — assert the concatenated content.
expect(container.textContent).toContain("new wording here");
// Apply button is present.
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
// No Applied badge yet.
expect(screen.queryByText("Applied")).toBeNull();
});
it("hides the Apply button when canEdit is false", () => {
const { container } = renderItem(suggestion(), false);
// Diff still renders (as per-fragment spans, #331)...
expect(container.textContent).toContain("new wording here");
// ...but no Apply button.
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("shows an Applied badge (no Apply button) once suggestionAppliedAt is set", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true);
expect(screen.getByText("Applied")).toBeDefined();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides the Apply button once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true);
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("calls the apply mutation when the Apply button is clicked", () => {
applyMutateAsync.mockClear();
renderItem(suggestion(), true);
fireEvent.click(screen.getByRole("button", { name: "Apply" }));
expect(applyMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
it("does not render the diff block for a reply (child) comment", () => {
renderItem(
suggestion({ parentCommentId: "c-0" }),
true,
);
expect(screen.queryByText("new wording here")).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
});
describe("CommentListItem — dismiss suggestion (#329)", () => {
const suggestion = (over?: Partial<IComment>): IComment =>
baseComment({
selection: "old wording here",
suggestedText: "new wording here",
...over,
});
// A space admin (userSpaceRole="admin") satisfies the owner-or-admin gate
// regardless of who authored the comment; the tests below use it as the lever
// since the currentUser atom is unseeded (null) in this harness.
it("renders a Dismiss button alongside Apply when canEdit and canComment (owner/admin)", () => {
renderItem(suggestion(), true, true, "admin");
expect(screen.getByRole("button", { name: "Apply" })).toBeDefined();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("shows Dismiss but NOT Apply for an admin commenter who cannot edit", () => {
renderItem(suggestion(), false, true, "admin");
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
expect(screen.getByRole("button", { name: "Dismiss" })).toBeDefined();
});
it("hides Dismiss when the viewer cannot comment", () => {
renderItem(suggestion(), false, false, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
expect(screen.queryByRole("button", { name: "Apply" })).toBeNull();
});
it("hides Dismiss for a non-owner non-admin even with canComment (#338 F5: mirrors server 403)", () => {
// canComment=true but NOT a space admin and NOT the comment owner (the
// currentUser atom is null while the comment is authored by user-1), so the
// server would 403 a dismiss — the button must not be shown at all.
renderItem(suggestion(), false, true, "member");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss once the thread is resolved", () => {
renderItem(suggestion({ resolvedAt: new Date() }), true, true, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
});
it("hides Dismiss (shows the Applied badge) once applied", () => {
renderItem(suggestion({ suggestionAppliedAt: new Date() }), true, true, "admin");
expect(screen.queryByRole("button", { name: "Dismiss" })).toBeNull();
expect(screen.getByText("Applied")).toBeDefined();
});
it("calls the dismiss mutation when the Dismiss button is clicked", () => {
dismissMutateAsync.mockClear();
renderItem(suggestion(), true, true, "admin");
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
expect(dismissMutateAsync).toHaveBeenCalledWith({
commentId: "c-1",
pageId: "page-1",
});
});
});
describe("canShowApply predicate", () => {
const c = (over?: Partial<IComment>): IComment =>
({ suggestedText: "x", ...over }) as IComment;
@@ -1,6 +1,6 @@
import { Group, Text, Box, Badge, Button } from "@mantine/core";
import { Group, Text, Box } from "@mantine/core";
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
import React, { useMemo, useRef, useState } from "react";
import React, { useRef, useState } from "react";
import classes from "./comment.module.css";
import { useAtom, useAtomValue } from "jotai";
import { useTimeAgo } from "@/hooks/use-time-ago";
@@ -12,18 +12,11 @@ import CommentMenu from "@/features/comment/components/comment-menu";
import ResolveComment from "@/features/comment/components/resolve-comment";
import { useHover } from "@mantine/hooks";
import {
useApplySuggestionMutation,
useDeleteCommentMutation,
useDismissSuggestionMutation,
useResolveCommentMutation,
useUpdateCommentMutation,
} from "@/features/comment/queries/comment-query";
import { IComment } from "@/features/comment/types/comment.types";
import {
canShowApply,
canShowDismiss,
computeSuggestionDiff,
} from "@/features/comment/utils/suggestion";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { useTranslation } from "react-i18next";
@@ -32,13 +25,21 @@ interface CommentListItemProps {
comment: IComment;
pageId: string;
canComment: boolean;
// Real page-edit permission (page.permissions.canEdit) — gates the suggestion
// "Apply" button. Distinct from `canComment`, which may be looser (viewers
// allowed to comment cannot apply edits).
// Real page-edit permission (page.permissions.canEdit). Kept on the props for
// parity with the container's wiring even though the thread row itself no
// longer renders the suggestion Apply button (that moved to AgentEditCard).
canEdit?: boolean;
userSpaceRole?: string;
}
// Type B — the thread ROW. Renders a single human OR agent-without-edit comment
// in the redesigned visual: provenance avatar, author + timeago, hover-revealed
// resolve + edit/delete menu, the anchored selection quote, and the body through
// the static CommentContentView (or the inline TipTap editor while editing). It
// is used for both a top-level thread comment and, recursively, each reply row.
// ALL wiring (update/delete/resolve mutations, owner/admin gate, anchor nav) is
// the same logic the old row carried — only the presentation changed, and the
// agent suggested-edit block was lifted out into AgentEditCard.
function CommentListItem({
comment,
pageId,
@@ -55,29 +56,20 @@ function CommentListItem({
const updateCommentMutation = useUpdateCommentMutation();
const deleteCommentMutation = useDeleteCommentMutation(comment.pageId);
const resolveCommentMutation = useResolveCommentMutation();
const applySuggestionMutation = useApplySuggestionMutation();
const dismissSuggestionMutation = useDismissSuggestionMutation();
const [currentUser] = useAtom(currentUserAtom);
const createdAtAgo = useTimeAgo(comment.createdAt);
// Intraline "before -> after" diff (#331) for a suggested edit: only the
// fragments that actually changed get emphasised inside the red/green block,
// instead of striking through / greening the whole line. Memoised on the
// (selection, suggestedText) pair so it recomputes only when they change.
const suggestionDiff = useMemo(
() =>
comment.suggestedText != null
? computeSuggestionDiff(comment.selection ?? "", comment.suggestedText)
: null,
[comment.selection, comment.suggestedText],
);
// `canEdit`/`pageId` are threaded through for wiring parity with the container;
// the thread row does not itself gate on them (Apply lives on AgentEditCard).
void canEdit;
void pageId;
// Owner-or-space-admin gate (#338): mirrors the server authz for both the
// comment menu (edit/delete) and the suggestion Dismiss button, so we never
// render an action the server will 403.
// Owner-or-space-admin gate (#338): mirrors the server authz for the comment
// menu (edit/delete), so we never render an action the server will 403.
const isOwnerOrAdmin =
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
const isAgent = comment.createdSource === "agent" && !!comment.agent;
async function handleUpdateComment() {
try {
@@ -121,34 +113,9 @@ function CommentListItem({
}
}
async function handleApplySuggestion() {
try {
await applySuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Errors surface via the mutation's onError notification (incl. 409).
console.error("Failed to apply suggestion:", error);
}
}
async function handleDismissSuggestion() {
try {
await dismissSuggestionMutation.mutateAsync({
commentId: comment.id,
pageId: comment.pageId,
});
} catch (error) {
// Idempotent races are reconciled to success in the mutation's onError;
// anything else surfaces there as a notification.
console.error("Failed to dismiss suggestion:", error);
}
}
function handleCommentClick(comment: IComment) {
function handleCommentClick(target: IComment) {
const el = document.querySelector(
`.comment-mark[data-comment-id="${comment.id}"]`,
`.comment-mark[data-comment-id="${target.id}"]`,
);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "center" });
@@ -169,10 +136,10 @@ function CommentListItem({
return (
<Box ref={ref} pb={6}>
<Group gap="xs">
{comment.createdSource === "agent" && comment.agent ? (
<Group gap="xs" wrap="nowrap" align="flex-start">
{isAgent ? (
<AgentAvatarStack
agent={comment.agent}
agent={comment.agent!}
launcher={comment.launcher}
aiChatId={comment.aiChatId}
showName={false}
@@ -185,13 +152,13 @@ function CommentListItem({
/>
)}
<div style={{ flex: 1 }}>
<div style={{ flex: 1, minWidth: 0 }}>
<Group justify="space-between" wrap="nowrap">
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
{comment.createdSource === "agent" && comment.agent ? (
{isAgent ? (
<>
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
{comment.agent.name}
{comment.agent!.name}
</Text>
{comment.launcher && (
<>
@@ -262,87 +229,6 @@ function CommentListItem({
</Box>
)}
{/* Suggested-edit (#315): "было → стало" diff for a top-level comment
carrying a suggestion. Old text struck-through/red, new text green. */}
{!comment.parentCommentId && comment.suggestedText && (
<Box className={classes.suggestionBlock}>
{comment.selection && (
// Old line: read as removed as a whole (line-through/red); only the
// changed fragments carry the extra intraline emphasis.
<Text size="xs" className={classes.suggestionOld}>
{suggestionDiff?.old.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
)}
<Text size="xs" className={classes.suggestionNew}>
{suggestionDiff?.new.map((segment, index) => (
<span
key={index}
className={segment.changed ? classes.suggestionChanged : undefined}
>
{segment.text}
</span>
))}
</Text>
{comment.suggestionAppliedAt ? (
<Badge
size="sm"
color="green"
variant="light"
mt={6}
aria-label={t("Applied")}
>
{t("Applied")}
</Badge>
) : (
(canShowApply(comment, canEdit) ||
canShowDismiss(comment, canComment, isOwnerOrAdmin)) && (
<Group gap="xs" mt={6}>
{canShowApply(comment, canEdit) && (
<Button
size="compact-xs"
variant="light"
color="green"
onClick={handleApplySuggestion}
loading={applySuggestionMutation.isPending}
disabled={
applySuggestionMutation.isPending ||
dismissSuggestionMutation.isPending
}
>
{t("Apply")}
</Button>
)}
{/* Dismiss ("Не применять", #329): removes the suggestion
without changing the page text. Gated on canComment. */}
{canShowDismiss(comment, canComment, isOwnerOrAdmin) && (
<Button
size="compact-xs"
variant="subtle"
color="gray"
onClick={handleDismissSuggestion}
loading={dismissSuggestionMutation.isPending}
disabled={
applySuggestionMutation.isPending ||
dismissSuggestionMutation.isPending
}
>
{t("Dismiss")}
</Button>
)}
</Group>
)
)}
</Box>
)}
{!isEditing ? (
<CommentContentView content={comment.content} />
) : (
@@ -350,7 +236,9 @@ function CommentListItem({
<CommentEditor
defaultContent={comment.content}
editable={true}
onUpdate={(newContent: any) => { editContentRef.current = newContent; }}
onUpdate={(newContent: any) => {
editContentRef.current = newContent;
}}
onSave={handleUpdateComment}
autofocus={true}
/>
@@ -5,7 +5,7 @@ import {
Center,
Divider,
Group,
Paper,
Box,
Stack,
Tabs,
Badge,
@@ -14,6 +14,9 @@ import {
Tooltip,
} from "@mantine/core";
import CommentListItem from "@/features/comment/components/comment-list-item";
import AgentEditCard, {
RunHeader,
} from "@/features/comment/components/agent-edit-card";
import {
useCommentsQuery,
useCreateCommentMutation,
@@ -22,6 +25,10 @@ import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
import { useFocusWithin } from "@mantine/hooks";
import { IComment } from "@/features/comment/types/comment.types.ts";
import {
groupAgentRuns,
CommentRenderUnit,
} from "@/features/comment/utils/group-agent-runs";
import { usePageMetaQuery } from "@/features/page/queries/page-query.ts";
import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next";
@@ -69,6 +76,32 @@ export function sortResolvedByResolvedAt(resolved: IComment[]): IComment[] {
);
}
// The redesigned card shell: a rounded, bordered surface on the panel body. Both
// a thread card and an agent-run group live inside one of these.
function PanelCard({
children,
...rest
}: {
children: React.ReactNode;
[key: string]: unknown;
}) {
return (
<Box
m="8px 10px"
p={0}
style={{
background: "var(--mantine-color-body)",
border: "1px solid var(--mantine-color-default-border)",
borderRadius: 10,
overflow: "hidden",
}}
{...rest}
>
{children}
</Box>
);
}
function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
const { t } = useTranslation();
const { pageSlug } = useParams();
@@ -90,6 +123,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
const userSpaceRole = space?.membership?.role;
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
if (!comments?.items) {
@@ -113,6 +148,17 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
};
}, [comments]);
// Collapse each tab's top-level list into render units (a lone comment or a
// collapsed agent run). Purely visual — the underlying data is untouched.
const activeUnits = useMemo(
() => groupAgentRuns(activeComments),
[activeComments],
);
const resolvedUnits = useMemo(
() => groupAgentRuns(resolvedComments),
[resolvedComments],
);
// Index replies by their parent once, instead of an O(n^2) filter per thread.
// The map ref changes on any comments update, so MemoizedChildComments re-runs
// (cheap) and re-looks-up, while memoized CommentListItems skip unchanged items.
@@ -168,56 +214,104 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
[createCommentAsync, page?.id],
);
const renderComments = useCallback(
(comment: IComment) => (
<Paper
shadow="sm"
radius="md"
p="xs"
mb="xs"
withBorder
key={comment.id}
data-comment-id={comment.id}
>
<div>
<CommentListItem
// The full subtree for ONE top-level comment: its head card (thread row or
// agent edit card), its nested replies, and a lazily-mounted reply editor.
// Shared by a standalone card and a card inside an agent-run group so the
// reply threading / lazy editor (#340) is wired identically in both.
const renderCommentSubtree = useCallback(
(comment: IComment, isEdit: boolean, showProvenance: boolean) => (
<>
{isEdit ? (
<AgentEditCard
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
userSpaceRole={userSpaceRole}
showProvenance={showProvenance}
/>
) : (
<Box p="xs">
<CommentListItem
comment={comment}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={userSpaceRole}
/>
</Box>
)}
<Box px="xs">
<MemoizedChildComments
childrenByParent={childrenByParent}
parentId={comment.id}
pageId={page?.id}
canComment={canComment}
canEdit={canEdit}
userSpaceRole={space?.membership?.role}
userSpaceRole={userSpaceRole}
/>
</div>
</Box>
{!comment.resolvedAt && canComment && (
<>
<Box px="xs" pb="xs">
<Divider my={2} />
<CommentEditorWithActions
commentId={comment.id}
onSave={handleAddReply}
/>
</>
</Box>
)}
</Paper>
</>
),
[
childrenByParent,
handleAddReply,
page?.id,
space?.membership?.role,
userSpaceRole,
canComment,
canEdit,
],
);
// Render one collapsed unit: a standalone card (thread or lone edit) or an
// agent-run group (one RunHeader over N stacked edit cards).
const renderUnit = useCallback(
(unit: CommentRenderUnit) => {
if (unit.kind === "single") {
const c = unit.comment;
const isEdit =
c.createdSource === "agent" &&
c.suggestedText != null &&
!c.parentCommentId;
return (
<PanelCard key={c.id} data-comment-id={c.id}>
{renderCommentSubtree(c, isEdit, true)}
</PanelCard>
);
}
// A collapsed agent run: one header, then each edit card (provenance
// suppressed on the cards — the header carries the single provenance line).
return (
<PanelCard key={unit.key}>
<RunHeader comments={unit.comments} />
{unit.comments.map((c) => (
<Box
key={c.id}
data-comment-id={c.id}
style={{
borderTop: "1px solid var(--mantine-color-default-border)",
}}
>
{renderCommentSubtree(c, true, false)}
</Box>
))}
</PanelCard>
);
},
[renderCommentSubtree],
);
if (isCommentsLoading) {
return <></>;
}
@@ -226,8 +320,6 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
return <div>{t("Error loading comments.")}</div>;
}
const totalComments = activeComments.length + resolvedComments.length;
const pageCommentInput = canComment ? (
<PageCommentInput
onSave={handleAddPageComment}
@@ -328,7 +420,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
</Stack>
</Center>
) : (
activeComments.map(renderComments)
activeUnits.map(renderUnit)
)}
</Tabs.Panel>
@@ -347,7 +439,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
</Stack>
</Center>
) : (
resolvedComments.map(renderComments)
resolvedUnits.map(renderUnit)
)}
</Tabs.Panel>
</div>
@@ -21,53 +21,6 @@
box-sizing: border-box;
}
/* Suggested-edit (#315) "было → стало" diff block. */
.suggestionBlock {
margin-top: 8px;
margin-left: 6px;
padding: 6px;
border-radius: var(--mantine-radius-sm);
border: 1px solid var(--mantine-color-default-border);
overflow-wrap: break-word;
word-break: break-word;
max-width: 100%;
box-sizing: border-box;
display: flex;
flex-direction: column;
align-items: flex-start;
}
.suggestionOld {
text-decoration: line-through;
color: var(--mantine-color-red-7);
background: var(--mantine-color-red-light);
border-radius: 2px;
padding: 1px 3px;
}
.suggestionNew {
color: var(--mantine-color-green-9);
background: var(--mantine-color-green-light);
border-radius: 2px;
padding: 1px 3px;
margin-top: 4px;
}
/* Intraline diff (#331): the fragment that actually changed within the
red "before" / green "after" block. It inherits the surrounding red/green
framing and adds a stronger tint plus bold weight so the eye lands on the
changed letters/words (git/GitHub-style) rather than the whole line. The
container's line-through (old) / green (new) still marks the full line. */
.suggestionChanged {
/* Stronger tint of the surrounding red/green so the changed fragment pops
within the block. `currentColor` follows the parent's red (old) or green
(new) text colour. No `text-decoration` here on purpose: the old block's
inherited line-through must survive on the changed letters too. */
background: color-mix(in srgb, currentColor 22%, transparent);
border-radius: 2px;
font-weight: 700;
}
.commentEditor {
&[data-editable][data-surface="muted"] .ProseMirror:not(.focused) {
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { groupAgentRuns, runKey, GROUP_MIN } from "./group-agent-runs";
import { IComment } from "@/features/comment/types/comment.types";
const editComment = (id: string, over?: Partial<IComment>): IComment =>
({
id,
createdSource: "agent",
aiChatId: "chat-1",
agent: { name: "Corrector" },
suggestedText: "new text",
parentCommentId: null,
...over,
}) as unknown as IComment;
const human = (id: string): IComment =>
({ id, createdSource: "user", parentCommentId: null }) as unknown as IComment;
describe("runKey", () => {
it("keys a groupable agent edit on aiChatId + agent.name", () => {
expect(runKey(editComment("a"))).toBe("chat-1:Corrector");
});
it("is null for an external MCP agent (aiChatId null)", () => {
expect(runKey(editComment("a", { aiChatId: null }))).toBeNull();
});
it("is null for a non-edit agent comment (no suggestedText)", () => {
expect(runKey(editComment("a", { suggestedText: null }))).toBeNull();
});
it("is null for a reply (has parentCommentId)", () => {
expect(runKey(editComment("a", { parentCommentId: "p" }))).toBeNull();
});
it("is null for a human comment", () => {
expect(runKey(human("a"))).toBeNull();
});
});
describe("groupAgentRuns", () => {
it("collapses >= GROUP_MIN same chat+role edits into one run at the first position", () => {
const units = groupAgentRuns([
editComment("e1"),
editComment("e2"),
editComment("e3"),
]);
expect(units).toHaveLength(1);
expect(units[0].kind).toBe("run");
if (units[0].kind === "run") {
expect(units[0].key).toBe("chat-1:Corrector");
expect(units[0].comments.map((c) => c.id)).toEqual(["e1", "e2", "e3"]);
}
expect(GROUP_MIN).toBe(2);
});
it("renders a lone edit as a single (below the threshold)", () => {
const units = groupAgentRuns([editComment("e1")]);
expect(units).toHaveLength(1);
expect(units[0].kind).toBe("single");
});
it("never groups external MCP edits (aiChatId null) — each is a single", () => {
const units = groupAgentRuns([
editComment("m1", { aiChatId: null }),
editComment("m2", { aiChatId: null }),
]);
expect(units).toHaveLength(2);
expect(units.every((u) => u.kind === "single")).toBe(true);
});
it("does not collapse two different roles sharing one chat", () => {
const units = groupAgentRuns([
editComment("a", { agent: { name: "Corrector" } as any }),
editComment("b", { agent: { name: "FactChecker" } as any }),
]);
// Each key has count 1 -> both remain singles.
expect(units).toHaveLength(2);
expect(units.every((u) => u.kind === "single")).toBe(true);
});
it("preserves order and keeps human threads as singles interleaved with a run", () => {
const units = groupAgentRuns([
human("h1"),
editComment("e1"),
editComment("e2"),
human("h2"),
]);
// h1 single, then the run (emitted at e1's position, e2 absorbed), then h2.
expect(units.map((u) => u.kind)).toEqual(["single", "run", "single"]);
if (units[1].kind === "run") {
expect(units[1].comments.map((c) => c.id)).toEqual(["e1", "e2"]);
}
});
});
@@ -0,0 +1,76 @@
import { IComment } from "@/features/comment/types/comment.types";
// A visual-only series threshold: this many agent suggested-edits sharing one
// run key collapse under a single RunHeader. A group of one renders as a plain
// standalone card (no header). Policy constant — no env override.
export const GROUP_MIN = 2;
// One rendered unit of the top-level comment list: either a collapsed agent-run
// group (>= GROUP_MIN suggested-edits from the same chat+role) or a single
// comment (a human thread, an agent thread without an edit, or a lone edit).
export interface AgentRunGroup {
kind: "run";
key: string;
comments: IComment[];
}
export interface SingleUnit {
kind: "single";
comment: IComment;
}
export type CommentRenderUnit = AgentRunGroup | SingleUnit;
// The grouping key of a top-level agent suggested-edit, or null when the comment
// is not a groupable edit. The key pins BOTH the AI chat and the acting role
// (`aiChatId + ":" + agent.name`) so two roles running in the same chat do not
// collapse under one header. A comment with `aiChatId == null` (an external MCP
// agent) has no chat to group by — it is deliberately never groupable and always
// renders as a single card (a time-bucketed synthetic run would split/merge runs
// arbitrarily and choke on ISO `createdAt`). PURE.
export function runKey(c: IComment): string | null {
if (
c.createdSource === "agent" &&
c.suggestedText != null &&
!c.parentCommentId &&
c.aiChatId != null &&
c.agent?.name
) {
return `${c.aiChatId}:${c.agent.name}`;
}
return null;
}
// Collapse an ORDERED list of top-level comments into render units, preserving
// list order: a run is emitted in place of its FIRST member (later members are
// absorbed), everything else stays a single unit. Grouping is computed over the
// snapshot handed in; a streamed page / WS update may later promote a single
// edit into a run as more members arrive — that is acceptable (purely visual).
// PURE — no React/DOM, no mutation of the input.
export function groupAgentRuns(comments: IComment[]): CommentRenderUnit[] {
// First pass: tally groupable edits per key so we know which keys clear
// GROUP_MIN before we start emitting.
const counts = new Map<string, number>();
for (const c of comments) {
const key = runKey(c);
if (key) counts.set(key, (counts.get(key) ?? 0) + 1);
}
const emitted = new Set<string>();
const units: CommentRenderUnit[] = [];
for (const c of comments) {
const key = runKey(c);
if (key && (counts.get(key) ?? 0) >= GROUP_MIN) {
// Emit the whole run once, at the position of its first member; skip the
// absorbed later members.
if (emitted.has(key)) continue;
emitted.add(key);
units.push({
kind: "run",
key,
comments: comments.filter((x) => runKey(x) === key),
});
} else {
units.push({ kind: "single", comment: c });
}
}
return units;
}
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { parseSeverity, isSignificant } from "./severity";
describe("parseSeverity — exact importance dictionary", () => {
it("maps the Russian importance tags", () => {
expect(parseSeverity("[Критично] fix now")).toBe("critical");
expect(parseSeverity("[Существенно] tighten")).toBe("major");
expect(parseSeverity("[Незначительно] nit")).toBe("minor");
});
it("maps the English equivalents and is case-insensitive", () => {
expect(parseSeverity("[Critical] x")).toBe("critical");
expect(parseSeverity("[MAJOR] x")).toBe("major");
expect(parseSeverity("[minor] x")).toBe("minor");
expect(parseSeverity("[критично] x")).toBe("critical");
});
it("treats fact-checker verdicts as unknown, NOT a severity", () => {
expect(parseSeverity("[Неверно] this is wrong")).toBe("unknown");
expect(parseSeverity("[Не проверено] can't verify")).toBe("unknown");
expect(parseSeverity("[Непроверяемо] x")).toBe("unknown");
expect(parseSeverity("[Это мнение] x")).toBe("unknown");
});
it("treats a typo'd / unrecognised tag as unknown (never falls back to minor)", () => {
expect(parseSeverity("[Существенное] typo ending")).toBe("unknown");
expect(parseSeverity("[whatever] x")).toBe("unknown");
});
it("returns unknown for no tag or empty input", () => {
expect(parseSeverity("plain body, no tag")).toBe("unknown");
expect(parseSeverity("")).toBe("unknown");
});
it("finds the recognised importance tag even after a leading verdict tag", () => {
expect(parseSeverity("[Неверно] [Существенно] body")).toBe("major");
});
});
describe("isSignificant", () => {
it("counts only major and critical", () => {
expect(isSignificant("critical")).toBe(true);
expect(isSignificant("major")).toBe(true);
expect(isSignificant("minor")).toBe(false);
expect(isSignificant("unknown")).toBe(false);
});
});
@@ -0,0 +1,44 @@
// Severity of an agent suggested-edit, parsed CLIENT-SIDE from the importance
// tag the editorial role prompts emit at the head of the comment body
// (`[Критично]` / `[Существенно]` / `[Незначительно]`, or their English
// equivalents). It is a DISPLAY-ONLY signal: it drives the coloured dot on the
// edit card and the "M major" counter in a run header. It never gates an action.
//
// `unknown` is a first-class value, NOT a synonym for `minor`: any bracketed
// text that is not in the exact dictionary — a Fact-checker verdict
// (`[Неверно]`, `[Не проверено]`, `[Непроверяемо]`, `[Это мнение]`), a typo
// (`[Существенное]`), or the absence of a tag — resolves to `unknown` and draws
// a neutral dot. Passing an unrecognised tag off as `minor` would mis-label it.
export type Severity = "critical" | "major" | "minor" | "unknown";
// EXACT (case-insensitive) dictionary. Only these tokens map to a real severity;
// everything else falls through to `unknown` on purpose.
const SEVERITY_TAGS: Record<string, Severity> = {
критично: "critical",
существенно: "major",
незначительно: "minor",
critical: "critical",
major: "major",
minor: "minor",
};
// Parse the first RECOGNISED importance tag out of the flattened comment text
// (use `commentContentToText` to obtain it). Scans every `[...]` token so a tag
// that trails a verdict still counts; the first exact-dictionary hit wins. When
// no token matches the dictionary the result is `unknown`. PURE — no React/DOM.
export function parseSeverity(text: string): Severity {
if (!text) return "unknown";
const matches = text.matchAll(/\[([^\]]+)\]/g);
for (const m of matches) {
const tag = m[1].trim().toLowerCase();
const sev = SEVERITY_TAGS[tag];
if (sev) return sev;
}
return "unknown";
}
// Whether a severity counts toward the "M major" tally in a run header: only the
// genuinely significant ones (major + critical). `minor` and `unknown` do not.
export function isSignificant(severity: Severity): boolean {
return severity === "major" || severity === "critical";
}
@@ -92,13 +92,23 @@
wrapper, never in the document model) and is rendered inline at the start of
the first content line via ::before. This keeps text and wrapped lines flush
to the left margin — no hanging indent — while the editable contentDOM stays
the FIRST DOM child (#146). */
the FIRST DOM child (#146).
The rules below target `p:first-child`, NOT `> :first-child`: tiptap-react
wraps the NodeViewContent children in an extra block-level div
(`[data-node-view-content-react]`, style="white-space: inherit"), so
`.definitionContent`'s first child is that wrapper, not the paragraph. An
inline ::before on the block wrapper (whose child is a block <p>) drops onto
its own line above the text — the "+1 line" regression. `p:first-child`
reaches the real first paragraph so the number stays inline, and it works
whether or not the wrapper is present (definition content is `paragraph+`,
so there are no nested paragraphs to mis-match). */
.definitionContent {
flex: 1 1 auto;
min-width: 0;
}
.definitionContent > :first-child::before {
.definitionContent p:first-child::before {
content: var(--footnote-number, "?") ". ";
color: var(--mantine-color-dimmed);
font-variant-numeric: tabular-nums;
@@ -108,12 +118,13 @@
/* The inner editable paragraph inherits `.ProseMirror p { margin: 0.5em 0 }`.
Drop the outer margins so the definition sits tight to the heading above and
the ::before number aligns with the top of the row — same approach used for
callouts in core.css. */
.definitionContent > :first-child {
callouts in core.css. Target the paragraphs directly (through the
tiptap-react content wrapper), same reasoning as the ::before rule above. */
.definitionContent p:first-child {
margin-top: 0;
}
.definitionContent > :last-child {
.definitionContent p:last-child {
margin-bottom: 0;
}
-16
View File
@@ -14,19 +14,3 @@ export function execCommandCopy(text: string): void {
document.execCommand("copy");
document.body.removeChild(textarea);
}
// Stateless one-shot copy: write `text` to the clipboard without ever storing it
// in React state (unlike useClipboard, which keeps a `copied` flag AND holds the
// last value). Used by the api-key reveal/copy flow, where the secret must touch
// nothing but the clipboard — no component state, no cache, no localStorage.
export async function copyToClipboard(text: string): Promise<void> {
if (typeof navigator !== "undefined" && "clipboard" in navigator) {
try {
await navigator.clipboard.writeText(text);
return;
} catch {
// Fall through to the execCommand fallback (e.g. insecure context).
}
}
execCommandCopy(text);
}
@@ -22,9 +22,6 @@ export const AuditEvent = {
API_KEY_CREATED: 'api_key.created',
API_KEY_UPDATED: 'api_key.updated',
API_KEY_DELETED: 'api_key.deleted',
// A copyable key was re-minted and returned to its owner under a password
// step-up (see ApiKeyController.reveal). Durable via DatabaseAuditService.
API_KEY_REVEALED: 'api_key.revealed',
// SCIM Tokens
SCIM_TOKEN_CREATED: 'scim_token.created',
@@ -32,6 +32,14 @@ describe('EmbeddingIndexerService.reindexWorkspace fail-fast', () => {
const pageEmbeddingRepo = {};
const aiService = {
getEmbeddingModel: jest.fn().mockResolvedValue('some-model'),
// #530: reindexWorkspace's pre-check now resolves the provider (workspace
// or global). Resolve it so the batch control flow under test proceeds.
resolveEmbeddingProvider: jest.fn().mockResolvedValue({
model: 'some-model',
queryPrefix: '',
docPrefix: '',
fingerprint: 'fp-test',
}),
};
// Progress is a best-effort cosmetic store; mock its async methods so the
// batch control flow can be tested without Redis.
@@ -108,6 +116,14 @@ describe('EmbeddingIndexerService.reindexWorkspace progress', () => {
const pageEmbeddingRepo = {};
const aiService = {
getEmbeddingModel: jest.fn().mockResolvedValue('some-model'),
// #530: reindexWorkspace's pre-check now resolves the provider (workspace
// or global). Resolve it so the batch control flow under test proceeds.
resolveEmbeddingProvider: jest.fn().mockResolvedValue({
model: 'some-model',
queryPrefix: '',
docPrefix: '',
fingerprint: 'fp-test',
}),
};
const reindexProgress = {
start: jest.fn().mockResolvedValue(undefined),
@@ -174,7 +190,7 @@ describe('EmbeddingIndexerService.reindexWorkspace progress', () => {
const { service, aiService, reindexProgress } = makeService();
// Embeddings not configured: reindexWorkspace returns early WITHOUT starting
// a fresh record, but the finally must still clear the enqueue-time seed.
aiService.getEmbeddingModel = jest
aiService.resolveEmbeddingProvider = jest
.fn()
.mockRejectedValue(new AiEmbeddingNotConfiguredException());
@@ -187,3 +203,87 @@ describe('EmbeddingIndexerService.reindexWorkspace progress', () => {
expect(reindexProgress.clear).toHaveBeenCalledWith(WORKSPACE_ID);
});
});
/**
* #530 PR-1: reindexPage must (a) prepend the provider's DOC prefix to each chunk
* BEFORE embedding (so stored vectors live in the same prefixed space as a
* prefixed query), and (b) stamp the ACTIVE fingerprint on every inserted row (so
* search only fuses same-generation vectors). Uses lightweight mocks; the tx is
* stubbed to run its callback inline.
*/
describe('EmbeddingIndexerService.reindexPage doc-prefix + fingerprint (#530)', () => {
const WORKSPACE_ID = 'ws-1';
const SPACE_ID = 'space-1';
const PAGE_ID = 'page-1';
function makeService(docPrefix: string) {
const pageRepo = {
findById: jest.fn().mockResolvedValue({
id: PAGE_ID,
workspaceId: WORKSPACE_ID,
spaceId: SPACE_ID,
title: 'Заголовок',
// No ProseMirror content -> the plain-text fallback path (single chunk).
content: null,
textContent: 'простой текст страницы',
deletedAt: null,
}),
};
const insertChunks = jest.fn().mockResolvedValue(undefined);
const pageEmbeddingRepo = {
deleteByPage: jest.fn().mockResolvedValue(undefined),
insertChunks,
};
const embedWithModel = jest.fn().mockResolvedValue([[0.1, 0.2, 0.3]]);
const aiService = {
resolveEmbeddingProvider: jest.fn().mockResolvedValue({
model: { modelId: 'e5-small' },
queryPrefix: 'query: ',
docPrefix,
fingerprint: 'fp-gen-1',
}),
embedWithModel,
};
const reindexProgress = {};
// Stub the tx so executeTx runs its callback inline against a fake trx.
const db = {
transaction: () => ({ execute: (cb: any) => cb({}) }),
};
const service = new EmbeddingIndexerService(
pageRepo as unknown as PageRepo,
pageEmbeddingRepo as unknown as PageEmbeddingRepo,
aiService as unknown as AiService,
reindexProgress as unknown as EmbeddingReindexProgressService,
db as unknown as KyselyDB,
);
return { service, embedWithModel, insertChunks };
}
it('prepends the doc prefix to each chunk and stamps the fingerprint on rows', async () => {
const { service, embedWithModel, insertChunks } = makeService('passage: ');
await service.reindexPage(PAGE_ID);
// Embedded values are DOC-prefixed; the model is the resolved provider model.
expect(embedWithModel).toHaveBeenCalledTimes(1);
const [modelArg, wsArg, valuesArg] = embedWithModel.mock.calls[0];
expect(modelArg).toEqual({ modelId: 'e5-small' });
expect(wsArg).toBe(WORKSPACE_ID);
expect(valuesArg).toEqual(['passage: простой текст страницы']);
// Inserted rows carry the active fingerprint and the ORIGINAL (un-prefixed)
// content (the prefix is an embedding-space artifact, not stored text).
expect(insertChunks).toHaveBeenCalledTimes(1);
const rows = insertChunks.mock.calls[0][0];
expect(rows).toHaveLength(1);
expect(rows[0].fingerprint).toBe('fp-gen-1');
expect(rows[0].content).toBe('простой текст страницы');
expect(rows[0].modelName).toBe('e5-small');
});
it('does not prefix when the provider has an empty doc prefix', async () => {
const { service, embedWithModel } = makeService('');
await service.reindexPage(PAGE_ID);
const [, , valuesArg] = embedWithModel.mock.calls[0];
expect(valuesArg).toEqual(['простой текст страницы']);
});
});
@@ -108,15 +108,24 @@ export class EmbeddingIndexerService {
return;
}
// Resolve embeddings config WITHOUT crashing the queue when unconfigured.
// Resolve the embeddings provider WITHOUT crashing the queue when
// unconfigured. #530: resolveEmbeddingProvider prefers the workspace provider
// and falls back to the GLOBAL env provider (TEI sidecar), and yields the
// doc-prefix + the active fingerprint stored per row so search filters by the
// active generation.
let modelName = 'unknown';
let provider: Awaited<
ReturnType<AiService['resolveEmbeddingProvider']>
>;
try {
const model = await this.aiService.getEmbeddingModel(workspaceId);
provider = await this.aiService.resolveEmbeddingProvider(workspaceId);
// Record the model id per row so a future migration can detect + re-index
// rows produced by a different model (see the migration header). The SDK
// type is `string | EmbeddingModel{V2,V3}`; model objects carry `modelId`.
modelName =
typeof model === 'string' ? model : (model.modelId ?? 'unknown');
typeof provider.model === 'string'
? provider.model
: (provider.model.modelId ?? 'unknown');
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
// No embeddings provider for this workspace: NO-OP (§6.7). The page can
@@ -145,8 +154,18 @@ export class EmbeddingIndexerService {
return;
}
// Embed all chunks in one batch.
const vectors = await this.aiService.embedTexts(workspaceId, chunks);
// #530: prepend the provider's DOC prefix to each chunk (e5-style
// "passage: "; empty for a non-e5 provider) so stored vectors live in the
// same prefixed space as a prefixed query, then embed with the RESOLVED
// provider model (which may be the global TEI sidecar, not a workspace one).
const prefixedChunks = provider.docPrefix
? chunks.map((c) => provider.docPrefix + c)
: chunks;
const vectors = await this.aiService.embedWithModel(
provider.model,
workspaceId,
prefixedChunks,
);
// The column is dimension-agnostic, so ANY model dimension is stored as-is.
// Defensive sanity check only: all chunks of ONE page come from the SAME
@@ -170,6 +189,7 @@ export class EmbeddingIndexerService {
vectors,
{ pageId, workspaceId, spaceId },
modelName,
provider.fingerprint,
);
// HARD replace in one transaction: delete then insert so search never
@@ -216,7 +236,9 @@ export class EmbeddingIndexerService {
// (seeded at enqueue time); the finally cleans that too.
try {
try {
await this.aiService.getEmbeddingModel(workspaceId);
// #530: resolve via the same path reindexPage uses (workspace provider,
// else the global TEI sidecar) so a global-only deployment is NOT skipped.
await this.aiService.resolveEmbeddingProvider(workspaceId);
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
this.logger.log(
@@ -348,6 +370,7 @@ export class EmbeddingIndexerService {
vectors: number[][],
ids: { pageId: string; workspaceId: string; spaceId: string },
modelName: string,
fingerprint: string | null,
): PageEmbeddingChunkRow[] {
const rows: PageEmbeddingChunkRow[] = [];
let cursor = 0;
@@ -370,6 +393,8 @@ export class EmbeddingIndexerService {
// Provenance for a future re-index sweep on model change.
modelName,
modelDimensions: embedding.length,
// #530: the active generation fingerprint this row belongs to.
fingerprint,
embedding,
});
}
@@ -1,8 +1,4 @@
import {
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { ForbiddenException, NotFoundException } from '@nestjs/common';
import { ApiKeyController } from './api-key.controller';
import {
WorkspaceCaslAction,
@@ -16,8 +12,7 @@ import {
* — GitHub-PAT semantics closing post-revocation laundering.
* - admin (CASL Manage on API) sees/revokes all workspace keys; a member only
* their own.
* - reveal: password step-up (401 on wrong password), owner-only even for admin
* (404), api_key principal 403 — the copyable-key surface (#557).
* - kill-switch OFF -> the surface 404s (looks like the feature does not exist).
*/
function makeController(over: any = {}) {
@@ -27,7 +22,6 @@ function makeController(over: any = {}) {
key: { id: 'k1', name: 'n', expiresAt: null, createdAt: new Date() },
}),
revoke: jest.fn().mockResolvedValue(undefined),
reveal: jest.fn().mockResolvedValue('remint.tok'),
...(over.apiKeyService ?? {}),
};
const apiKeyRepo = {
@@ -42,16 +36,16 @@ function makeController(over: any = {}) {
}),
...(over.workspaceAbility ?? {}),
};
const authService = {
verifyUserCredentials: jest.fn().mockResolvedValue({ id: 'u-1' }),
...(over.authService ?? {}),
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(over.enabled ?? true),
...(over.environmentService ?? {}),
};
const auditService = { log: jest.fn() };
const controller = new ApiKeyController(
apiKeyService as any,
apiKeyRepo as any,
workspaceAbility as any,
authService as any,
environmentService as any,
auditService as any,
);
return {
@@ -59,12 +53,12 @@ function makeController(over: any = {}) {
apiKeyService,
apiKeyRepo,
workspaceAbility,
authService,
environmentService,
auditService,
};
}
const user = { id: 'u-1', email: 'u@x.io', workspaceId: 'ws-1' } as any;
const user = { id: 'u-1', workspaceId: 'ws-1' } as any;
const workspace = { id: 'ws-1' } as any;
const reqAccess = () => ({ raw: {}, ip: '1.2.3.4', socket: {} }) as any;
const reqApiKey = () =>
@@ -92,86 +86,20 @@ describe('ApiKeyController — a token cannot manage tokens', () => {
controller.revoke({ id: 'k1' } as any, user, workspace, reqApiKey()),
).rejects.toBeInstanceOf(ForbiddenException);
});
it('403 on reveal for an api_key principal (a key cannot reveal its siblings)', async () => {
const { controller, authService, apiKeyService } = makeController();
await expect(
controller.reveal(
{ id: 'k1', password: 'pw' } as any,
user,
workspace,
reqApiKey(),
),
).rejects.toBeInstanceOf(ForbiddenException);
// Rejected BEFORE the step-up and the key lookup even run.
expect(authService.verifyUserCredentials).not.toHaveBeenCalled();
expect(apiKeyService.reveal).not.toHaveBeenCalled();
});
});
describe('ApiKeyController — reveal (copyable key under step-up)', () => {
it('re-mints and returns the token + logs API_KEY_REVEALED on success', async () => {
const { controller, apiKeyService, authService, auditService } =
makeController();
const res = await controller.reveal(
{ id: 'k1', password: 'correct-horse' } as any,
user,
workspace,
reqAccess(),
);
expect(res).toEqual({ token: 'remint.tok' });
// Step-up ran with the caller's email + supplied password.
expect(authService.verifyUserCredentials).toHaveBeenCalledWith(
{ email: 'u@x.io', password: 'correct-horse' },
'ws-1',
);
expect(apiKeyService.reveal).toHaveBeenCalledWith({
apiKeyId: 'k1',
user,
workspaceId: 'ws-1',
});
expect(auditService.log).toHaveBeenCalledWith(
expect.objectContaining({ event: 'api_key.revealed' }),
);
});
it('401 on a wrong password — step-up runs BEFORE the key lookup (no oracle)', async () => {
const { controller, apiKeyService } = makeController({
authService: {
verifyUserCredentials: jest
.fn()
.mockRejectedValue(new UnauthorizedException()),
},
});
describe('ApiKeyController — kill-switch OFF → 404', () => {
it('create/list/revoke all 404 when disabled', async () => {
const { controller } = makeController({ enabled: false });
await expect(
controller.reveal(
{ id: 'k1', password: 'wrong' } as any,
user,
workspace,
reqAccess(),
),
).rejects.toBeInstanceOf(UnauthorizedException);
// The key is never looked up / re-minted when step-up fails.
expect(apiKeyService.reveal).not.toHaveBeenCalled();
});
it("propagates the service's uniform 404 (absent/revoked/expired/non-owner)", async () => {
const { controller, auditService } = makeController({
apiKeyService: {
reveal: jest.fn().mockRejectedValue(new NotFoundException()),
},
});
await expect(
controller.reveal(
{ id: 'k1', password: 'correct' } as any,
user,
workspace,
reqAccess(),
),
controller.create({ name: 'x' } as any, user, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.list(user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
await expect(
controller.revoke({ id: 'k1' } as any, user, workspace, reqAccess()),
).rejects.toBeInstanceOf(NotFoundException);
// No audit event on a failed reveal.
expect(auditService.log).not.toHaveBeenCalled();
});
});
@@ -2,7 +2,6 @@ import {
Body,
Controller,
ForbiddenException,
forwardRef,
HttpCode,
HttpStatus,
Inject,
@@ -18,7 +17,6 @@ import { ApiKeyService } from './api-key.service';
import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { RevokeApiKeyDto } from './dto/revoke-api-key.dto';
import { RevealApiKeyDto } from './dto/reveal-api-key.dto';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
@@ -33,7 +31,7 @@ import {
AUDIT_SERVICE,
IAuditService,
} from '../../integrations/audit/audit.service';
import { AuthService } from '../auth/services/auth.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { UserThrottlerGuard } from '../../integrations/throttle/user-throttler.guard';
import { AUTH_THROTTLER } from '../../integrations/throttle/throttler-names';
@@ -46,13 +44,18 @@ export class ApiKeyController {
private readonly apiKeyService: ApiKeyService,
private readonly apiKeyRepo: ApiKeyRepo,
private readonly workspaceAbility: WorkspaceAbilityFactory,
// forwardRef breaks the AuthModule <-> ApiKeyModule cycle (AuthModule imports
// ApiKeyModule for JwtStrategy). Only used for the reveal step-up.
@Inject(forwardRef(() => AuthService))
private readonly authService: AuthService,
private readonly environmentService: EnvironmentService,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {}
// Kill-switch OFF: the issuance surface disappears entirely (404), not a 403 —
// it must look like the feature does not exist.
private assertEnabled(): void {
if (!this.environmentService.isApiKeysEnabled()) {
throw new NotFoundException();
}
}
// A token cannot manage tokens (GitHub-PAT semantics): an api-key principal is
// refused on the management surface, so a leaked key cannot mint a replacement
// or revoke the keys that would lock it out (closes post-revocation laundering).
@@ -75,6 +78,7 @@ export class ApiKeyController {
@AuthUser() user: User,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
// undefined -> service default (1 year); null -> unlimited; string -> Date.
@@ -96,9 +100,8 @@ export class ApiKeyController {
resourceType: AuditResource.API_KEY,
resourceId: key.id,
});
// Durable audit is via DatabaseAuditService (#496); this structured log is a
// second, container-log trail. No token material — the JWT is only ever
// returned in the response.
// The durable trail lives in container logs (AUDIT_SERVICE is a Noop in this
// build). No token material — the JWT is only ever returned in the response.
this.logger.log(
`API key created: id=${key.id} name=${JSON.stringify(
key.name,
@@ -128,6 +131,7 @@ export class ApiKeyController {
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
const ability = this.workspaceAbility.createForUser(user, workspace);
@@ -159,6 +163,7 @@ export class ApiKeyController {
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.assertEnabled();
this.rejectApiKeyPrincipal(req);
const key = await this.apiKeyRepo.findById(dto.id, workspace.id);
@@ -193,64 +198,4 @@ export class ApiKeyController {
return { success: true };
}
/**
* Reveal (re-mint) a copyable token for an existing key, under a password
* step-up. The token is hashed nowhere and stored nowhere — "reveal" re-mints
* the SAME deterministic value (see TokenService.generateApiToken /
* ApiKeyService.reveal) and returns it in the body only.
*
* Defence layers (triple, against exfiltration through a hijacked session):
* - rejectApiKeyPrincipal: an api_key Bearer caller is 403 — a leaked key must
* NOT be able to reveal its siblings (key laundering); a token doesn't manage
* tokens.
* - step-up: the password is re-verified (verifyUserCredentials) BEFORE any key
* lookup, so a wrong/absent password is a uniform 401 that leaks nothing about
* the key. SSO/MFA-only users (no local password) are out of scope (homelab).
* - AUTH throttler: same limiter as create.
*
* Owner-only, EVEN for an admin, and every key-state negative (absent / revoked /
* expired / another user's / creator disabled) is a UNIFORM 404 (see
* ApiKeyService.reveal) — no existence/state oracle.
*/
@HttpCode(HttpStatus.OK)
@UseGuards(JwtAuthGuard, UserThrottlerGuard)
@Throttle({ [AUTH_THROTTLER]: { limit: 10, ttl: 60_000 } })
@Post('reveal')
async reveal(
@Body() dto: RevealApiKeyDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Req() req: FastifyRequest,
) {
this.rejectApiKeyPrincipal(req);
// Step-up FIRST — before any key lookup — so the 401 for a bad password never
// depends on (and never leaks) the key's existence/state. Reuses the exact
// timing-safe, SSO-aware credential check used by login (throws a uniform
// 401 on wrong/absent/SSO-only password).
await this.authService.verifyUserCredentials(
{ email: user.email, password: dto.password },
workspace.id,
);
const token = await this.apiKeyService.reveal({
apiKeyId: dto.id,
user,
workspaceId: workspace.id,
});
// Durable audit (DatabaseAuditService, #496) + a structured container-log
// trail. NEVER the token material.
this.auditService.log({
event: AuditEvent.API_KEY_REVEALED,
resourceType: AuditResource.API_KEY,
resourceId: dto.id,
});
this.logger.log(
`API key revealed: id=${dto.id} actor=${user.id} ip=${this.clientIp(req)}`,
);
return { token };
}
}
@@ -1,8 +1,7 @@
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { ApiKeyController } from './api-key.controller';
import { TokenModule } from '../auth/token.module';
import { AuthModule } from '../auth/auth.module';
// Core (non-EE) API-key feature: issuance REST endpoints + the shared validator
// consumed by jwt.strategy (REST) and McpService (the /mcp Bearer router).
@@ -12,10 +11,7 @@ import { AuthModule } from '../auth/auth.module';
// jwt.strategy) and McpModule (for the /mcp router) can inject it directly,
// replacing the absent EE `ee/api-key` dynamic require.
@Module({
// forwardRef(AuthModule): the reveal endpoint's password step-up uses
// AuthService.verifyUserCredentials. AuthModule already imports ApiKeyModule
// (for JwtStrategy), so the two form a cycle that forwardRef resolves.
imports: [TokenModule, forwardRef(() => AuthModule)],
imports: [TokenModule],
controllers: [ApiKeyController],
providers: [ApiKeyService],
exports: [ApiKeyService],
@@ -1,8 +1,4 @@
import {
ForbiddenException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { UnauthorizedException } from '@nestjs/common';
import { ApiKeyService } from './api-key.service';
import { JwtType } from '../auth/dto/jwt-payload';
@@ -20,6 +16,8 @@ import { JwtType } from '../auth/dto/jwt-payload';
* - No validate cache (each call re-reads the row → immediate revocation).
*/
const APP_SECRET = 'secret';
function makeDeps(over: Partial<Record<string, any>> = {}) {
const apiKeyRepo = {
findById: jest.fn(),
@@ -40,13 +38,20 @@ function makeDeps(over: Partial<Record<string, any>> = {}) {
generateApiToken: jest.fn().mockResolvedValue('minted.jwt.token'),
...(over.tokenService ?? {}),
};
const environmentService = {
isApiKeysEnabled: jest.fn().mockReturnValue(true),
getApiKeysEnabledRaw: jest.fn().mockReturnValue(undefined),
getAppSecret: jest.fn().mockReturnValue(APP_SECRET),
...(over.environmentService ?? {}),
};
const service = new (ApiKeyService as unknown as new (...a: any[]) => ApiKeyService)(
apiKeyRepo,
userRepo,
workspaceRepo,
tokenService,
environmentService,
);
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService };
return { service, apiKeyRepo, userRepo, workspaceRepo, tokenService, environmentService };
}
const payload = (over: Record<string, any> = {}) => ({
@@ -143,6 +148,10 @@ describe('ApiKeyService.validate', () => {
d.workspaceRepo.findById.mockResolvedValue(undefined);
},
],
[
'kill-switch OFF',
(d) => d.environmentService.isApiKeysEnabled.mockReturnValue(false),
],
[
'malformed payload (missing apiKeyId)',
() => undefined,
@@ -166,16 +175,14 @@ describe('ApiKeyService.validate', () => {
},
);
// --- Kill-switch removed: keys work with no env variable ------------------
it('validates with no API_KEYS_ENABLED env (kill-switch fully removed)', async () => {
// The service no longer takes EnvironmentService — there is no env gate left.
const { service, apiKeyRepo, userRepo } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
userRepo.findById.mockResolvedValue(activeUser());
it('kill-switch OFF denies BEFORE any DB read', async () => {
const { service, apiKeyRepo, environmentService } = makeDeps();
environmentService.isApiKeysEnabled.mockReturnValue(false);
await expect(service.validate(payload() as any)).resolves.toMatchObject({
user: { id: 'u-1' },
});
await expect(service.validate(payload() as any)).rejects.toBeInstanceOf(
UnauthorizedException,
);
expect(apiKeyRepo.findById).not.toHaveBeenCalled();
});
// --- Infra error propagates (5xx), NOT masked as 401 ---------------------
@@ -290,90 +297,3 @@ describe('ApiKeyService.create (mint-then-insert, no exp)', () => {
expect(apiKeyRepo.insert).not.toHaveBeenCalled();
});
});
/**
* ApiKeyService.reveal — the KEY-STATE gate for the copyable-key flow. Owner-only
* (even for an admin), and EVERY negative is a uniform NotFoundException so there
* is no existence/state oracle. Re-mint is deterministic (delegated to
* TokenService); here we only assert the gate + normalization.
*/
describe('ApiKeyService.reveal', () => {
const caller = (over: Record<string, any> = {}) =>
({ id: 'u-1', email: 'u@x.io', workspaceId: 'ws-1', ...over }) as any;
it('re-mints and returns the token for a live key owned by the caller', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
tokenService.generateApiToken.mockResolvedValue('remint.tok');
const token = await service.reveal({
apiKeyId: 'key-1',
user: caller(),
workspaceId: 'ws-1',
});
expect(token).toBe('remint.tok');
// Re-mints against the SAME id + the caller (== creator) so the value matches.
expect(tokenService.generateApiToken).toHaveBeenCalledWith({
apiKeyId: 'key-1',
user: expect.objectContaining({ id: 'u-1' }),
workspaceId: 'ws-1',
});
});
it('404 for a missing/revoked key (findById returns undefined)', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(undefined);
await expect(
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
).rejects.toBeInstanceOf(NotFoundException);
// No re-mint on any negative.
expect(tokenService.generateApiToken).not.toHaveBeenCalled();
});
it("404 for another user's key — owner-only, even for an admin", async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
// The caller is a workspace admin (irrelevant here — reveal ignores CASL).
apiKeyRepo.findById.mockResolvedValue(activeRow({ creatorId: 'someone-else' }));
await expect(
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
).rejects.toBeInstanceOf(NotFoundException);
expect(tokenService.generateApiToken).not.toHaveBeenCalled();
});
it('404 for an expired key (expiry read from the row) — no dead token issued', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(
activeRow({ expiresAt: new Date(Date.now() - 1000) }),
);
await expect(
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
).rejects.toBeInstanceOf(NotFoundException);
expect(tokenService.generateApiToken).not.toHaveBeenCalled();
});
it('normalizes a disabled-creator ForbiddenException (403) to a uniform 404', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
// generateApiToken throws ForbiddenException when isUserDisabled(user).
tokenService.generateApiToken.mockRejectedValue(new ForbiddenException());
await expect(
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('propagates an UNEXPECTED re-mint error (not masked as 404)', async () => {
const { service, apiKeyRepo, tokenService } = makeDeps();
apiKeyRepo.findById.mockResolvedValue(activeRow());
const boom = new Error('signer exploded');
tokenService.generateApiToken.mockRejectedValue(boom);
await expect(
service.reveal({ apiKeyId: 'key-1', user: caller(), workspaceId: 'ws-1' }),
).rejects.toBe(boom);
});
});
+23 -66
View File
@@ -1,8 +1,7 @@
import {
ForbiddenException,
Injectable,
Logger,
NotFoundException,
OnModuleInit,
UnauthorizedException,
} from '@nestjs/common';
import { v7 as uuid7 } from 'uuid';
@@ -10,6 +9,7 @@ import { ApiKeyRepo } from '@docmost/db/repos/api-key/api-key.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { TokenService } from '../auth/services/token.service';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { JwtApiKeyPayload } from '../auth/dto/jwt-payload';
import { ApiKey, User, Workspace } from '@docmost/db/types/entity.types';
import { isUserDisabled } from '../../common/helpers';
@@ -31,7 +31,7 @@ const LAST_USED_THROTTLE_MS = 60 * 60 * 1000;
* key's lifetime and revocation — never the JWT, which carries no `exp` claim.
*/
@Injectable()
export class ApiKeyService {
export class ApiKeyService implements OnModuleInit {
private readonly logger = new Logger(ApiKeyService.name);
constructor(
@@ -39,8 +39,20 @@ export class ApiKeyService {
private readonly userRepo: UserRepo,
private readonly workspaceRepo: WorkspaceRepo,
private readonly tokenService: TokenService,
private readonly environmentService: EnvironmentService,
) {}
onModuleInit() {
// Boot log so the kill-switch state after each deploy is verifiable in logs.
const enabled = this.environmentService.isApiKeysEnabled();
const raw = this.environmentService.getApiKeysEnabledRaw();
this.logger.log(
`API keys: ${enabled ? 'ENABLED' : 'DISABLED'} (API_KEYS_ENABLED=${
raw ?? 'unset'
})`,
);
}
/**
* Mint a new key for `user`. mint-then-insert ordering (R1):
* 1. generate the id first — it must be in the JWT payload before the row.
@@ -89,8 +101,8 @@ export class ApiKeyService {
* path returns) so the AuthUser/AuthWorkspace decorators and MCP identity work
* unchanged.
*
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact —
* missing/revoked/expired row, workspace mismatch, disabled user —
* Failure semantics (R4, anti-enumeration): a DEFINITE negative fact — feature
* disabled, missing/revoked/expired row, workspace mismatch, disabled user —
* throws a bare `UnauthorizedException` (a single generic 401 for every case;
* an agent cannot distinguish expired from revoked, and its reaction is
* identical). An UNEXPECTED (infra) error is NOT caught here: it propagates so
@@ -102,6 +114,12 @@ export class ApiKeyService {
async validate(
payload: JwtApiKeyPayload,
): Promise<{ user: User; workspace: Workspace }> {
// Kill-switch OFF: deny unconditionally (same generic 401). Note the
// endpoints additionally 404 at the controller; here we deny the token.
if (!this.environmentService.isApiKeysEnabled()) {
throw new UnauthorizedException();
}
if (!payload?.apiKeyId || !payload?.sub || !payload?.workspaceId) {
throw new UnauthorizedException();
}
@@ -156,67 +174,6 @@ export class ApiKeyService {
await this.apiKeyRepo.softDelete(id, workspaceId);
}
/**
* Re-mint (reveal) the token for an EXISTING key so its owner can copy it again.
* The token material is never stored, so "reveal" = re-generate the same value:
* generateApiToken is deterministic (no `iat`/`exp`, fixed payload), so the
* re-minted token is byte-identical to the original for the same key.
*
* Authorization/step-up (principal rejection + password) is the caller's job
* (the controller). This method owns the KEY-STATE gate and the re-mint. Every
* negative is a UNIFORM `NotFoundException` — there is NO existence/state oracle:
* a caller cannot tell "absent" from "revoked" from "expired" from "another
* user's key" from "creator disabled". Only a live key owned by `user` re-mints.
*
* `user` is the authenticated caller AND (after the owner check) the key's
* creator, so the re-minted token's `sub` matches the original mint — passing
* `user` directly avoids re-fetching the creator row.
*/
async reveal(opts: {
apiKeyId: string;
user: User;
workspaceId: string;
}): Promise<string> {
const { apiKeyId, user, workspaceId } = opts;
const row = await this.apiKeyRepo.findById(apiKeyId, workspaceId);
// Absent = revoked (soft-deleted), orphaned, or never existed -> uniform 404.
if (!row) {
throw new NotFoundException();
}
// Owner-only, EVEN for an admin: a working revealed token is an impersonation
// of the creator, so unlike list/revoke, admin must NOT reveal others' keys.
// Someone else's key looks exactly like a missing one -> uniform 404.
if (row.creatorId !== user.id) {
throw new NotFoundException();
}
// Expiry is read from the ROW (findById filters only deletedAt, so an expired
// row is still returned). A dead key must not hand out a working token, and a
// uniform 404 keeps the anti-enumeration property.
if (row.expiresAt && row.expiresAt.getTime() <= Date.now()) {
throw new NotFoundException();
}
try {
// Deterministic re-mint (byte-identical to the original for this key).
return await this.tokenService.generateApiToken({
apiKeyId: row.id,
user,
workspaceId,
});
} catch (err) {
// A disabled creator makes generateApiToken throw ForbiddenException (403).
// Normalize to the SAME 404 so reveal never leaks "user is blocked" vs "key
// does not exist". Unexpected errors propagate (5xx), never masked.
if (err instanceof ForbiddenException) {
throw new NotFoundException();
}
throw err;
}
}
// Throttled best-effort last_used_at bump: skip if it was touched within the
// window; otherwise fire-and-forget so a stamp write never fails or slows the
// request (mirrors SessionActivityService.trackActivity).
@@ -1,14 +0,0 @@
import { IsNotEmpty, IsString, IsUUID } from 'class-validator';
// Step-up payload for `POST /api-keys/reveal`: the key to re-mint + the caller's
// current password. The password is re-verified server-side (AuthService.
// verifyUserCredentials) before the key is ever looked up, so a copyable,
// non-expiring token cannot be exfiltrated through a merely-hijacked session.
export class RevealApiKeyDto {
@IsUUID()
id: string;
@IsString()
@IsNotEmpty()
password: string;
}
+3 -4
View File
@@ -1,4 +1,4 @@
import { forwardRef, Module } from '@nestjs/common';
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './services/auth.service';
import { JwtStrategy } from './strategies/jwt.strategy';
@@ -10,9 +10,8 @@ import { ApiKeyModule } from '../api-key/api-key.module';
@Module({
// ApiKeyModule supplies ApiKeyService, injected into JwtStrategy so an
// api_key Bearer/cookie token is validated directly (replacing the absent EE
// `ee/api-key` dynamic require). forwardRef: ApiKeyModule imports AuthModule
// back (for the reveal step-up), so the two form a cycle.
imports: [TokenModule, WorkspaceModule, forwardRef(() => ApiKeyModule)],
// `ee/api-key` dynamic require).
imports: [TokenModule, WorkspaceModule, ApiKeyModule],
controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy],
exports: [SignupService, AuthService],
@@ -306,41 +306,6 @@ describe('TokenService.generateApiToken (no exp claim ever)', () => {
});
});
// #557: the copyable-key contract. noTimestamp suppresses `iat`, so the token
// is a pure deterministic function of (payload, secret) — re-minting the SAME
// key yields a BYTE-IDENTICAL value, which is what makes "reveal" a safe
// re-mint rather than a stored secret.
it('mints an api-key JWT with NEITHER exp NOR iat (deterministic)', async () => {
const { service } = makeRealSignerService();
const token = await service.generateApiToken({
apiKeyId: 'key-1',
user: user as never,
workspaceId: 'ws-1',
});
const decoded = jwt.decode(token) as Record<string, unknown>;
expect(decoded.exp).toBeUndefined();
expect(decoded.iat).toBeUndefined();
});
it('two mints of the SAME key are byte-identical (re-mint = reveal)', async () => {
const { service } = makeRealSignerService();
const opts = {
apiKeyId: 'key-1',
user: user as never,
workspaceId: 'ws-1',
};
const first = await service.generateApiToken(opts);
// A different time (and a fresh signer instance) must not change the bytes.
await new Promise((r) => setTimeout(r, 1100));
const { service: service2 } = makeRealSignerService();
const second = await service2.generateApiToken(opts);
expect(second).toBe(first);
});
it('demonstrates the live bug it guards: the SHARED signer WOULD add exp', () => {
const { JwtService } = require('@nestjs/jwt');
const sharedJwt = new JwtService({
@@ -155,31 +155,19 @@ export class TokenService {
// signer would silently get exp=now+90d and die in 90 days regardless of its
// row. We mint through a dedicated no-expiry signer, re-stamping only
// `issuer: 'Docmost'` for claim parity with the shared signer.
//
// The dedicated signer ALSO sets `noTimestamp: true`, so the token carries
// neither `exp` nor `iat`. The payload is a fixed literal { sub, apiKeyId,
// workspaceId, type } in a stable order, so the HS256 signature is a pure
// deterministic function of (sub, apiKeyId, workspaceId, APP_SECRET): minting
// the SAME key twice yields a BYTE-IDENTICAL token. This is what makes the key
// "copyable" — the reveal endpoint (#557) re-mints the same value under a
// step-up without ever persisting the token material.
return this.apiKeyJwtService().sign(payload);
}
// Lazily-built JWT signer for API-key tokens: same APP_SECRET, same 'Docmost'
// issuer, but WITHOUT the global `expiresIn` — so minted API-key tokens have no
// `exp` claim. `noTimestamp: true` additionally suppresses the default `iat`
// claim jsonwebtoken would otherwise add, making the minted token a fully
// deterministic function of its payload + secret (byte-identical re-mints, the
// basis of the copyable/reveal flow). Built once and cached. Verification still
// goes through the shared verifier (same secret); `verifyAsync` does not
// require an `exp` or `iat`.
// `exp` claim. Built once and cached. Verification still goes through the
// shared verifier (same secret); `verifyAsync` does not require an `exp`.
private _apiKeyJwtService?: JwtService;
private apiKeyJwtService(): JwtService {
if (!this._apiKeyJwtService) {
this._apiKeyJwtService = new JwtService({
secret: this.environmentService.getAppSecret(),
signOptions: { issuer: 'Docmost', noTimestamp: true },
signOptions: { issuer: 'Docmost' },
});
}
return this._apiKeyJwtService;
@@ -32,10 +32,27 @@ export class SearchResultDto {
matchedTerms: string[];
}
// The paginated envelope (A5). `total` is the EXACT permission-filtered count of
// pages matching the positive lexical query (fail-closed). `hasMore` is true when
// more results exist WITHIN the fusion window; `truncatedAtCap` signals the match
// set exceeded CANDIDATE_CAP and the tail is unreachable by pagination.
// #530 Phase B — the semantic (vector) layer's per-request status.
// - `state`: 'full' when a provider resolved and the vector arm ran this
// request; 'off' otherwise (kill-switch, no provider, or a degrade).
// - `available`: whether the vector arm actually contributed this request.
// - `reason`: why the arm did NOT run — 'no-provider' (no embedding provider
// resolved) or 'degraded' (sidecar down / query embed timed out).
// - `indexed`/`total`: reserved for PR-2 coverage reporting (unused in PR-1).
export class SearchSemanticDto {
state: 'full' | 'off';
available: boolean;
reason?: 'no-provider' | 'degraded';
indexed?: number;
total?: number;
}
// The paginated envelope (A5). `total` is the permission-filtered count of the
// candidate UNION (lexical ∪ vector top-N, fail-closed) — a deliberate, #530
// documented change from Phase A's exact-lexical count (on a semantic degrade it
// falls back to exactly that lexical count). `hasMore` is true when more results
// exist WITHIN the fusion window; `truncatedAtCap` signals the match set exceeded
// CANDIDATE_CAP and the tail is unreachable by pagination.
export class SearchResponseDto {
items: SearchResultDto[];
total: number;
@@ -53,4 +70,7 @@ export class SearchResponseDto {
mode: 'or' | 'and';
match: string;
};
// Absent on the early-exit paths (empty/garbage query); present once search
// actually runs. Optional so those short-circuit responses stay valid.
semantic?: SearchSemanticDto;
}
@@ -1,8 +1,15 @@
import { Module } from '@nestjs/common';
import { SearchController } from './search.controller';
import { SearchService } from './search.service';
import { AiModule } from '../../integrations/ai/ai.module';
/**
* #530: AiModule supplies AiService (embedQuery / resolveEmbeddingProvider).
* PageEmbeddingRepo is provided by the @Global DatabaseModule, so it is injected
* into SearchService without an explicit import here.
*/
@Module({
imports: [AiModule],
controllers: [SearchController],
providers: [SearchService],
exports: [SearchService],
@@ -48,6 +48,10 @@ describe('SearchService.searchPage — scope-security early returns', () => {
shareRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
// #530 semantic path off for these query-mode unit tests: a stubbed
// embedQuery that throws makes the service degrade to the lexical path.
{ embedQuery: jest.fn().mockRejectedValue(new Error('no embed')) } as any,
{ vectorCandidateArm: jest.fn() } as any,
);
return { service, pageRepo, shareRepo, spaceMemberRepo, pagePermissionRepo };
}
@@ -11,6 +11,8 @@ describe('SearchService', () => {
{} as any, // shareRepo
{} as any, // spaceMemberRepo
{} as any, // pagePermissionRepo
{} as any, // aiService
{} as any, // pageEmbeddingRepo
);
expect(service).toBeDefined();
});
@@ -61,6 +63,8 @@ describe('SearchService.searchSuggestions — onlyTemplates filter', () => {
shareRepo as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
{} as any, // aiService (searchSuggestions never embeds)
{} as any, // pageEmbeddingRepo
);
return { service, db, pageBuilder };
+247 -28
View File
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { SearchDTO, SearchSuggestionDTO } from './dto/search.dto';
import {
SearchResponseDto,
SearchResultDto,
SearchSemanticDto,
} from './dto/search-response.dto';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
@@ -11,6 +12,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { AiService } from '../../integrations/ai/ai.service';
import { AiEmbeddingNotConfiguredException } from '../../integrations/ai/ai-embedding-not-configured.exception';
import { isStatementTimeout } from '@docmost/db/utils';
import {
ParsedQuery,
ParsedTerm,
@@ -59,14 +64,42 @@ function defaultBooleanMode(): SearchBooleanMode {
return process.env.SEARCH_MODE === 'and' ? 'and' : 'or';
}
// #530 semantic env knobs. SEARCH_SEMANTIC=off is the kill-switch (hybrid is
// otherwise transparent — no per-request mode flag). SEARCH_VECTOR_WEIGHT tunes
// the vector RRF leg's contribution (default 1.0, i.e. equal to each lexical
// leg). SEARCH_VECTOR_CANDIDATES caps the vector top-N pulled into the union.
function semanticKillSwitchOff(): boolean {
return process.env.SEARCH_SEMANTIC === 'off';
}
function getVectorWeight(): number {
const raw = Number(process.env.SEARCH_VECTOR_WEIGHT);
return Number.isFinite(raw) && raw >= 0 ? raw : 1.0;
}
function getVectorCandidates(): number {
const raw = Number(process.env.SEARCH_VECTOR_CANDIDATES);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 50;
}
// Safety net for the brute-force vector scan (no ANN index — see
// vectorCandidateArm): a per-statement timeout bounding ONLY the fused vector
// query, so a pathological seq scan is cancelled (SQLSTATE 57014) and search
// degrades to lexical instead of hanging the request. Default 2000ms.
function getVectorStatementTimeoutMs(): number {
const raw = Number(process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS);
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 2000;
}
@Injectable()
export class SearchService {
private readonly logger = new Logger(SearchService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private pageRepo: PageRepo,
private shareRepo: ShareRepo,
private spaceMemberRepo: SpaceMemberRepo,
private pagePermissionRepo: PagePermissionRepo,
private aiService: AiService,
private pageEmbeddingRepo: PageEmbeddingRepo,
) {}
// === #529 SQL fragment builders (parameterized AST, never string-concat) =====
@@ -292,33 +325,102 @@ export class SearchService {
const scopeSql = sql`(${sql.join(scopePreds, sql` AND `)})`;
const candidateSql = sql`(${scopeSql} AND ${this.candidatePredicate(parsed, titleOnly)})`;
// --- Semantic (vector) arm: embed the query, degrade gracefully. (#530) ----
// The try/catch wraps ONLY embedQuery + vector-arm construction. On ANY throw
// (TEI down / 800ms timeout / no provider) we omit the vector arm entirely and
// run the byte-identical Phase-A lexical path — never a 500 from the sidecar.
// The permission filter below stays OUTSIDE this try (a permission error must
// 500, never fail-open). Hybrid is transparent; SEARCH_SEMANTIC=off disables it.
let vectorArm: RawBuilder<unknown> | null = null;
let semanticAvailable = false;
let semanticReason: 'no-provider' | 'degraded' | undefined;
if (!semanticKillSwitchOff()) {
try {
const { vector, fingerprint } = await this.aiService.embedQuery(
opts.workspaceId,
rawQuery,
);
vectorArm = this.pageEmbeddingRepo.vectorCandidateArm({
queryEmbedding: vector,
dimensions: vector.length,
fingerprint,
scope: scopeSql,
limit: getVectorCandidates(),
// Filter page_embeddings by (immutable) workspace_id directly, so the
// composite index bites on its leading column and candidates are
// workspace-scoped at the embedding level (#530 review WARNING 2). Space
// scoping stays on the pages join only — page_embeddings.space_id can be
// STALE after a cross-space move, so filtering it here would drop a moved
// page's vector hit (re-review regression fix).
workspaceId: scope.workspaceId,
});
semanticAvailable = true;
} catch (err) {
if (err instanceof AiEmbeddingNotConfiguredException) {
// No embedding provider is the DEFAULT state of any deployment without
// the TEI sidecar — normal, not a problem. Log at DEBUG so it never
// floods WARN once per search request.
semanticReason = 'no-provider';
this.logger.debug(
`search.semantic.no-provider workspace=${opts.workspaceId}`,
);
} else {
// A provider IS configured but the embed call failed/timed out — a real
// sidecar problem worth a WARN. Structured, greppable event (fixed code)
// — no query text/secrets.
semanticReason = 'degraded';
this.logger.warn(
`search.semantic.degraded reason=degraded workspace=${opts.workspaceId}`,
);
}
}
}
// --- Ranked candidate ids (ALL matches, RRF order). -----------------------
// Two independent branches ranked SEPARATELY (FTS by ts_rank_cd, substring by
// tier) then fused with RRF: score = Σ 1/(k + rank_branch). Deterministic
// ORDER BY rrf DESC, id so pagination never dupes/skips (A4, acceptance #8).
const rankedRows = await sql<{ id: string }>`
WITH candidates AS (
SELECT pages.id AS id,
${this.ftsScoreExpr(parsed)} AS fts_score,
${this.subTierExpr(parsed, titleOnly)} AS sub_tier
FROM pages
WHERE ${candidateSql}
),
ranked AS (
SELECT id, fts_score, sub_tier,
row_number() OVER (ORDER BY fts_score DESC NULLS LAST, id) AS rn_fts,
row_number() OVER (ORDER BY sub_tier DESC NULLS LAST, id) AS rn_sub
FROM candidates
)
SELECT id
FROM ranked
ORDER BY
(CASE WHEN fts_score IS NOT NULL THEN 1.0/(${RRF_K} + rn_fts) ELSE 0 END)
+ (CASE WHEN sub_tier IS NOT NULL THEN 1.0/(${RRF_K} + rn_sub) ELSE 0 END) DESC,
id ASC
`.execute(this.db);
// Lexical branches ranked SEPARATELY (FTS by ts_rank_cd, substring by tier);
// when a query vector is available a third VECTOR branch is fused over the
// UNION of lexical + vector candidates. RRF: score = Σ w/(k + rank_branch).
// Deterministic ORDER BY rrf DESC, id so pagination never dupes/skips (A4,
// acceptance #8). `total` is thus the union size (lexical ∪ vector top-N),
// post-permission-filter — a documented change from Phase A's exact lexical
// count (#530). On degrade the query is byte-identical to Phase A.
//
// The fused query is bounded by a per-statement timeout (see runRankedQuery):
// if the brute-force vector scan is cancelled (SQLSTATE 57014) we degrade to
// the byte-identical lexical path — search returns lexical results, never
// hangs, never 500s. Only the timeout is caught here; any OTHER SQL error
// propagates. The permission filter below stays outside, unchanged.
let orderedIds: string[];
try {
orderedIds = await this.runRankedQuery(
candidateSql,
parsed,
titleOnly,
vectorArm,
);
} catch (err) {
if (vectorArm && isStatementTimeout(err)) {
semanticAvailable = false;
semanticReason = 'degraded';
this.logger.warn(
`search.semantic.degraded reason=degraded workspace=${opts.workspaceId} (vector statement timeout)`,
);
// Re-run the exact Phase-A lexical path (no vector arm, no timeout).
orderedIds = await this.runRankedQuery(
candidateSql,
parsed,
titleOnly,
null,
);
} else {
throw err;
}
}
let orderedIds = rankedRows.rows.map((r) => r.id);
const semantic: SearchSemanticDto = {
state: semanticAvailable ? 'full' : 'off',
available: semanticAvailable,
...(semanticReason ? { reason: semanticReason } : {}),
};
// --- Permission filter (fail-closed, exact total). ------------------------
// filterAccessiblePageIds runs the #348 hasRestricted pre-check internally:
@@ -355,14 +457,131 @@ export class SearchService {
const hasMore = offset + pageIds.length < window.length;
if (pageIds.length === 0) {
return { items: [], total, hasMore, truncatedAtCap, offset, query: queryMeta };
return {
items: [],
total,
hasMore,
truncatedAtCap,
offset,
query: queryMeta,
semantic,
};
}
// --- Detail fetch for the page slice only (ts_headline/snippet are costly, so
// compute them ONLY for the returned rows), preserving RRF order. -------
const items = await this.fetchDetails(pageIds, parsed, titleOnly);
return { items, total, hasMore, truncatedAtCap, offset, query: queryMeta };
return {
items,
total,
hasMore,
truncatedAtCap,
offset,
query: queryMeta,
semantic,
};
}
/**
* Run the ranked-candidate-ids query and return the ids in RRF order (#530).
*
* When `vectorArm` is null (degrade / semantic off / no query vector) this runs
* the BYTE-IDENTICAL Phase-A 2-branch lexical query the semantic layer must
* be a pure superset that never changes lexical behaviour on degrade.
*
* When a vector arm is supplied it is UNION ALL'd with the lexical arm into one
* `candidates` set; `agg` collapses each page to its best per-branch signal
* (MAX fts_score / MAX sub_tier / MIN vec_distance); `ranked` assigns a
* per-branch row_number; the final ORDER BY fuses the three legs with RRF,
* adding the vector leg ONLY for rows that have a vec_distance (a lexical-only
* hit contributes 0 to the vector leg, and vice-versa). W_VEC weights the
* vector leg.
*/
private async runRankedQuery(
candidateSql: RawBuilder<unknown>,
parsed: ParsedQuery,
titleOnly: boolean,
vectorArm: RawBuilder<unknown> | null,
): Promise<string[]> {
if (!vectorArm) {
// Phase-A lexical path — DO NOT change (byte-identical on degrade, #529).
const rankedRows = await sql<{ id: string }>`
WITH candidates AS (
SELECT pages.id AS id,
${this.ftsScoreExpr(parsed)} AS fts_score,
${this.subTierExpr(parsed, titleOnly)} AS sub_tier
FROM pages
WHERE ${candidateSql}
),
ranked AS (
SELECT id, fts_score, sub_tier,
row_number() OVER (ORDER BY fts_score DESC NULLS LAST, id) AS rn_fts,
row_number() OVER (ORDER BY sub_tier DESC NULLS LAST, id) AS rn_sub
FROM candidates
)
SELECT id
FROM ranked
ORDER BY
(CASE WHEN fts_score IS NOT NULL THEN 1.0/(${RRF_K} + rn_fts) ELSE 0 END)
+ (CASE WHEN sub_tier IS NOT NULL THEN 1.0/(${RRF_K} + rn_sub) ELSE 0 END) DESC,
id ASC
`.execute(this.db);
return rankedRows.rows.map((r) => r.id);
}
// #530 fused 3-branch RRF over lexical ∪ vector candidates. The lexical arm
// adds NULL::float vec_distance to stay UNION-compatible with the vector arm.
const wVec = getVectorWeight();
// Bound the brute-force vector scan with a per-statement timeout, scoped to
// THIS query only. `set_config(..., is_local := true)` = SET LOCAL semantics:
// it applies within this transaction and auto-resets at COMMIT/ROLLBACK, so
// it can NEVER leak onto the next query that reuses this pooled connection.
// On a cancellation the driver raises SQLSTATE 57014, which searchPage catches
// to degrade to lexical-only. Running inside a transaction also guarantees the
// SET and the ranked query share one connection.
const timeoutMs = getVectorStatementTimeoutMs();
return this.db.transaction().execute(async (trx) => {
await sql`SELECT set_config('statement_timeout', ${String(timeoutMs)}, true)`.execute(
trx,
);
const rankedRows = await sql<{ id: string }>`
WITH candidates AS (
(SELECT pages.id AS id,
${this.ftsScoreExpr(parsed)} AS fts_score,
${this.subTierExpr(parsed, titleOnly)} AS sub_tier,
NULL::float AS vec_distance
FROM pages
WHERE ${candidateSql})
UNION ALL
(${vectorArm})
),
agg AS (
SELECT id,
MAX(fts_score) AS fts_score,
MAX(sub_tier) AS sub_tier,
MIN(vec_distance) AS vec_distance
FROM candidates
GROUP BY id
),
ranked AS (
SELECT id, fts_score, sub_tier, vec_distance,
row_number() OVER (ORDER BY fts_score DESC NULLS LAST, id) AS rn_fts,
row_number() OVER (ORDER BY sub_tier DESC NULLS LAST, id) AS rn_sub,
row_number() OVER (ORDER BY vec_distance ASC NULLS LAST, id) AS rn_vec
FROM agg
)
SELECT id
FROM ranked
ORDER BY
(CASE WHEN fts_score IS NOT NULL THEN 1.0/(${RRF_K} + rn_fts) ELSE 0 END)
+ (CASE WHEN sub_tier IS NOT NULL THEN 1.0/(${RRF_K} + rn_sub) ELSE 0 END)
+ (CASE WHEN vec_distance IS NOT NULL THEN ${wVec}::float/(${RRF_K} + rn_vec) ELSE 0 END) DESC,
id ASC
`.execute(trx);
return rankedRows.rows.map((r) => r.id);
});
}
// Resolve the search scope: explicit space, the authed user's member spaces, or
@@ -0,0 +1,49 @@
import { type Kysely, sql } from 'kysely';
/**
* #530 Search Phase B (PR-1): add the embedding FINGERPRINT column.
*
* The fingerprint is a deterministic id of the embedding configuration that
* produced a row model id + revision + query/doc prefix scheme + dimensions
* (see AiService.computeEmbeddingFingerprint). Search filters vector candidates
* by the workspace's ACTIVE fingerprint so a revision bump or a prefix-scheme
* change never fuses incompatible vectors into results.
*
* PR-1 scope: this migration only ADDS the column (nullable existing rows stay
* NULL = legacy) and the composite index the vector-candidate scan uses. The full
* generational swap / GC lifecycle (target-fingerprint reindex, atomic flip,
* old-generation GC) is deliberately deferred to PR-2.
*
* Independent migration (per the #363 crash-loop net): it creates ONLY its own
* objects and never touches another migration's tables/indexes, so a partial
* failure cannot leave a shared object half-built.
*/
export async function up(db: Kysely<any>): Promise<void> {
// Nullable text column. Existing rows keep NULL (legacy generation) and are
// simply not matched by the active-fingerprint filter until re-indexed.
await sql`
ALTER TABLE page_embeddings
ADD COLUMN IF NOT EXISTS fingerprint text
`.execute(db);
// Composite btree supporting the scoped, dimension- + fingerprint-filtered
// vector-candidate scan (workspace_id + space_id + fingerprint + model_dimensions).
await db.schema
.createIndex('idx_page_embeddings_ws_space_fp_dim')
.ifNotExists()
.on('page_embeddings')
.columns(['workspace_id', 'space_id', 'fingerprint', 'model_dimensions'])
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema
.dropIndex('idx_page_embeddings_ws_space_fp_dim')
.ifExists()
.execute();
await sql`
ALTER TABLE page_embeddings
DROP COLUMN IF EXISTS fingerprint
`.execute(db);
}
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { sql } from 'kysely';
import { RawBuilder, sql } from 'kysely';
import * as pgvector from 'pgvector';
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
import { dbOrTx } from '../../utils';
@@ -35,6 +35,9 @@ export interface PageEmbeddingChunkRow {
content: string;
modelName: string;
modelDimensions: number;
// #530 PR-1: the active embedding fingerprint (see computeEmbeddingFingerprint).
// null only when no provider resolves (the indexer no-ops in that case).
fingerprint: string | null;
embedding: number[];
}
@@ -120,6 +123,7 @@ export class PageEmbeddingRepo {
content: row.content,
modelName: row.modelName,
modelDimensions: row.modelDimensions,
fingerprint: row.fingerprint,
// pgvector.toSql -> '[1,2,3]'; cast the bound literal to vector.
embedding: sql`${pgvector.toSql(row.embedding)}::vector`,
})),
@@ -127,6 +131,82 @@ export class PageEmbeddingRepo {
.execute();
}
/**
* #530: build the VECTOR candidate arm for SearchService's fused RRF union a
* page-level nearest-neighbour sub-select. Returns a `sql` fragment (not an
* executed query) so the caller can UNION ALL it with the lexical arm inside a
* single ranked-ids query.
*
* SCALING BOUNDARY: the column is dimension-agnostic, so it carries NO ANN
* index this is a brute-force O(N) KNN seq scan with `<=>`. Accepted for the
* small-tenant / homelab fork target (ANN + a pinned-dimension column are
* deferred). It is NOT unbounded, though: the caller (SearchService) runs this
* fused query under a per-statement timeout (SEARCH_VECTOR_STATEMENT_TIMEOUT_MS)
* and DEGRADES to lexical-only on a 57014 cancellation, so a pathological scan
* can never hang the interactive search request.
*
* The arm collapses a page's chunks to its best (MIN) cosine distance:
* SELECT pages.id, NULL fts_score, NULL sub_tier,
* MIN(pe.embedding <=> $qvec) AS vec_distance
* FROM page_embeddings pe JOIN pages ON pages.id = pe.page_id
* WHERE <scope> AND pe.model_dimensions = $dim AND pe.fingerprint = $fp
* GROUP BY pages.id ORDER BY vec_distance LIMIT $limit
*
* `scope` is SearchService's shared scope predicate (workspace + space/id set +
* creator + descendants + deleted_at), referencing the `pages` table it must
* be spliced verbatim so the vector candidate set mirrors the lexical scope
* EXACTLY, minus the lexical text predicate (vector candidates need not match
* text). The vector is bound via pgvector's `toSql(...)::vector`, and both the
* dimension and the ACTIVE fingerprint are filtered so `<=>` only ever compares
* compatible, same-generation vectors (pgvector errors on a dimension
* mismatch; a fingerprint mismatch would fuse incomparable vectors).
*
* `workspaceId` is ALSO filtered directly on `page_embeddings` not only via
* the pages join. It is IMMUTABLE (there are no cross-workspace page moves), so
* an embedding row's workspace_id always matches its page's, and this drops NO
* legitimate hit; it lets the composite index idx_page_embeddings_ws_space_fp_dim
* (workspace_id, space_id, fingerprint, model_dimensions) bite from its LEADING
* column, and workspace-scopes candidates at the embedding level (defense in
* depth).
*
* SPACE is deliberately NOT filtered on page_embeddings: `page_embeddings.space_id`
* is stamped at index time and is NOT updated when a page is MOVED between spaces
* (movePageToSpace does not reindex, and PAGE_MOVED_TO_SPACE has no reindex
* consumer), so a moved page's rows carry the OLD space until the next reindex. A
* `page_embeddings.space_id = ANY(scope)` predicate would then wrongly drop a
* legitimate vector hit for a page moved INTO the searched space. Space scoping
* is therefore enforced ONLY by the join to `pages` (pages.space_id scope,
* always current) the same way the lexical arm scopes, so no space leak.
*
* The NULL fts_score / sub_tier columns keep the arm UNION-compatible with the
* lexical arm's column list.
*/
vectorCandidateArm(params: {
queryEmbedding: number[];
dimensions: number;
fingerprint: string;
scope: RawBuilder<unknown>;
limit: number;
workspaceId: string;
}): RawBuilder<unknown> {
const qvec = sql`${pgvector.toSql(params.queryEmbedding)}::vector`;
return sql`
SELECT pages.id AS id,
NULL::float AS fts_score,
NULL::int AS sub_tier,
MIN(page_embeddings.embedding <=> ${qvec}) AS vec_distance
FROM page_embeddings
JOIN pages ON pages.id = page_embeddings.page_id
WHERE ${params.scope}
AND page_embeddings.workspace_id = ${params.workspaceId}
AND page_embeddings.model_dimensions = ${params.dimensions}
AND page_embeddings.fingerprint = ${params.fingerprint}
GROUP BY pages.id
ORDER BY vec_distance ASC
LIMIT ${params.limit}
`;
}
/**
* Cosine search over the embeddings, scoped to a workspace AND a set of
* spaces the caller may read (see semanticSearch access-scoping). Orders by
@@ -7,6 +7,11 @@ export interface PageEmbeddings {
spaceId: string;
modelName: string;
modelDimensions: number;
// #530 PR-1: the active embedding fingerprint (model id + revision + prefix
// scheme + dimensions). NULL on legacy rows written before this column existed.
// Search filters candidates by the ACTIVE fingerprint so a revision/prefix
// change never mixes incompatible vectors.
fingerprint: string | null;
workspaceId: string;
// Nullable: page-body embeddings have no attachment (only attachment chunks set it).
attachmentId: string | null;
+17
View File
@@ -108,6 +108,23 @@ export function isUniqueViolation(err: unknown): boolean {
return (err as { code?: unknown } | null | undefined)?.code === PG_UNIQUE_VIOLATION;
}
/** Postgres `query_canceled` SQLSTATE — raised when statement_timeout fires. */
const PG_QUERY_CANCELED = '57014';
/**
* Whether `err` is a Postgres statement-timeout cancellation (SQLSTATE `57014`).
* Used by #530 semantic search to degrade a slow vector scan to lexical-only
* instead of hanging/500-ing. Matches on the SQLSTATE (the driver surfaces it as
* `.code`), with a message fallback for any wrapper that drops the code.
*/
export function isStatementTimeout(err: unknown): boolean {
if ((err as { code?: unknown } | null | undefined)?.code === PG_QUERY_CANCELED) {
return true;
}
const msg = err instanceof Error ? err.message : '';
return /canceling statement due to statement timeout/i.test(msg);
}
/**
* The name of the UNIQUE index/constraint a `23505` error violated, or
* undefined. The `kysely-postgres-js` / `postgres@3.x` driver surfaces it as
@@ -0,0 +1,143 @@
import { AiService, computeEmbeddingFingerprint } from './ai.service';
/**
* #530 Search Phase B (PR-1) unit coverage for the embedding layer:
* - computeEmbeddingFingerprint: a revision bump / prefix toggle MUST change the
* fingerprint even when the bare model name + dimension are unchanged (it is
* deliberately NOT the bare model name);
* - embedQuery: applies the QUERY prefix and embeds under the short SEARCH embed
* timeout (not the long batch timeout);
* - embedWithModel: a hung model + short timeout degrades with a clear timeout
* error (the search caller then falls back to the lexical path).
*/
describe('computeEmbeddingFingerprint (#530)', () => {
const base = {
modelId: 'intfloat/multilingual-e5-small',
revision: 'sha-aaa',
queryPrefix: 'query: ',
docPrefix: 'passage: ',
dimensions: 384,
};
it('is deterministic for identical inputs', () => {
expect(computeEmbeddingFingerprint(base)).toBe(
computeEmbeddingFingerprint({ ...base }),
);
});
it('CHANGES on a revision bump (same model name + dimension)', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, revision: 'sha-bbb' });
expect(b).not.toBe(a);
});
it('CHANGES on a query-prefix toggle (same model name + dimension + revision)', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, queryPrefix: '' });
expect(b).not.toBe(a);
});
it('CHANGES on a doc-prefix toggle', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, docPrefix: '' });
expect(b).not.toBe(a);
});
it('CHANGES on a dimension change', () => {
const a = computeEmbeddingFingerprint(base);
const b = computeEmbeddingFingerprint({ ...base, dimensions: 768 });
expect(b).not.toBe(a);
});
it('is NOT the bare model name (guards the mutation)', () => {
// If the fingerprint were the bare model name, a revision bump would not
// change it — the tests above would red. Assert it directly too.
expect(computeEmbeddingFingerprint(base)).not.toBe(base.modelId);
});
it('distinguishes swapped prefixes — ("q","") != ("","q")', () => {
const a = computeEmbeddingFingerprint({
...base,
queryPrefix: 'q',
docPrefix: '',
});
const b = computeEmbeddingFingerprint({
...base,
queryPrefix: '',
docPrefix: 'q',
});
expect(a).not.toBe(b);
});
});
describe('AiService.embedQuery (#530)', () => {
function makeService(): AiService {
// The constructor body only stores its deps; embedQuery is fully driven by
// the spied resolveEmbeddingProvider + embedWithModel below.
return new AiService({} as any, {} as any, {} as any);
}
it('prepends the query prefix and returns the provider fingerprint', async () => {
const svc = makeService();
jest.spyOn(svc, 'resolveEmbeddingProvider').mockResolvedValue({
model: { modelId: 'm' } as any,
queryPrefix: 'query: ',
docPrefix: 'passage: ',
fingerprint: 'fp-active',
});
const embedSpy = jest
.spyOn(svc, 'embedWithModel')
.mockResolvedValue([[0.1, 0.2, 0.3]]);
const res = await svc.embedQuery('ws-1', 'кофе');
// The single embedded value carries the query prefix.
expect(embedSpy).toHaveBeenCalledTimes(1);
const [, wsArg, valuesArg, timeoutArg] = embedSpy.mock.calls[0];
expect(wsArg).toBe('ws-1');
expect(valuesArg).toEqual(['query: кофе']);
// Bound by the SHORT search-embed timeout (default 800ms), not the 120000ms
// batch timeout.
expect(timeoutArg).toBe(800);
expect(res).toEqual({ vector: [0.1, 0.2, 0.3], fingerprint: 'fp-active' });
});
it('propagates a no-provider error (drives the no-provider degrade)', async () => {
const svc = makeService();
const { AiEmbeddingNotConfiguredException } = await import(
'./ai-embedding-not-configured.exception'
);
jest
.spyOn(svc, 'resolveEmbeddingProvider')
.mockRejectedValue(new AiEmbeddingNotConfiguredException());
await expect(svc.embedQuery('ws-1', 'x')).rejects.toBeInstanceOf(
AiEmbeddingNotConfiguredException,
);
});
});
describe('AiService.embedWithModel timeout (#530)', () => {
it('degrades a hung model with a timeout error under the short window', async () => {
const svc = new AiService({} as any, {} as any, {} as any);
// A model whose doEmbed never resolves but honours the abort signal, so the
// AbortSignal.timeout fires and embedWithModel raises a clear timeout error.
const hangingModel: any = {
specificationVersion: 'v2',
provider: 'fake',
modelId: 'fake',
maxEmbeddingsPerCall: 100,
supportsParallelCalls: true,
doEmbed: ({ abortSignal }: { abortSignal?: AbortSignal }) =>
new Promise((_resolve, reject) => {
abortSignal?.addEventListener('abort', () => {
const err = new Error('The operation was aborted');
err.name = 'AbortError';
reject(err);
});
}),
};
await expect(
svc.embedWithModel(hangingModel, 'ws-1', ['x'], 30),
).rejects.toThrow(/timed out/i);
});
});
+162 -7
View File
@@ -23,6 +23,50 @@ import {
import { AiProviderCredentialsRepo } from '@docmost/db/repos/ai-chat/ai-provider-credentials.repo';
import { SecretBoxService } from '../crypto/secret-box';
import { AiDriver } from './ai.types';
import { createHash } from 'node:crypto';
/**
* A resolved embedding provider for #530 semantic search. `model` is the AI SDK
* embedding model; `queryPrefix`/`docPrefix` are prepended to a query / a stored
* chunk respectively (e5-style `"query: "` / `"passage: "`, empty for a non-e5
* provider); `fingerprint` is the deterministic id of the whole configuration.
*/
export interface ResolvedEmbeddingProvider {
model: EmbeddingModel;
queryPrefix: string;
docPrefix: string;
fingerprint: string;
}
/**
* Deterministic embedding FINGERPRINT (#530). Encodes model id + revision +
* prefix scheme + dimensions so that ANY of them changing (a revision bump, a
* prefix toggle) yields a DIFFERENT fingerprint even when the bare model name and
* dimension are unchanged. Deliberately NOT the bare model name: two rows from
* the same model but a different revision/prefix must not be fused together.
*
* Exported as a pure function so it can be unit-tested in isolation and reused by
* the indexer without a service instance.
*/
export function computeEmbeddingFingerprint(parts: {
modelId: string;
revision: string;
queryPrefix: string;
docPrefix: string;
dimensions: number | null;
}): string {
// The two prefixes are SEPARATE keys (not a concatenated string): JSON.stringify
// escapes each independently, so the prefix scheme ("a","") is distinct from
// ("","a") with no separator/collision hazard even if a prefix contains spaces.
const canonical = JSON.stringify({
m: parts.modelId,
r: parts.revision,
q: parts.queryPrefix,
d: parts.docPrefix,
dim: parts.dimensions ?? 0,
});
return createHash('sha256').update(canonical).digest('hex').slice(0, 32);
}
/**
* Optional chat-model override carried by an agent role (`ai_agent_roles.
@@ -362,11 +406,112 @@ export class AiService {
async embedTexts(workspaceId: string, texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const model = await this.getEmbeddingModel(workspaceId);
// Bound the embedding call: a slow/hung embeddings endpoint must fail loudly
// (and let the caller move on to the next page) instead of blocking forever.
// The single signal caps the WHOLE call, including the SDK's internal
// retries/backoff (embedMany defaults to maxRetries: 2).
const timeoutMs = AiService.embeddingTimeoutMs();
return this.embedWithModel(model, workspaceId, texts);
}
/**
* #530: resolve the embedding provider for a workspace. Prefers the workspace's
* own configured embedding provider; falls back to the GLOBAL env provider (a
* TEI sidecar via the OpenAI-compatible path) when the workspace has none.
* Returns the model + the query/doc prefixes + the config fingerprint. Throws
* AiEmbeddingNotConfiguredException when NEITHER resolves, so callers can drive
* a `no-provider` degrade path.
*/
async resolveEmbeddingProvider(
workspaceId: string,
): Promise<ResolvedEmbeddingProvider> {
// 1. Per-workspace provider (uses the workspace's own creds/endpoint). When
// it is not configured getEmbeddingModel throws the not-configured
// exception; we swallow ONLY that and fall through to the global provider.
try {
const model = await this.getEmbeddingModel(workspaceId);
const modelId =
typeof model === 'string' ? model : (model.modelId ?? 'unknown');
// A per-workspace (typically non-e5) provider gets no e5-style prefixes.
return {
model,
queryPrefix: '',
docPrefix: '',
fingerprint: computeEmbeddingFingerprint({
modelId,
revision: 'workspace',
queryPrefix: '',
docPrefix: '',
dimensions: null,
}),
};
} catch (err) {
if (!(err instanceof AiEmbeddingNotConfiguredException)) throw err;
}
// 2. Global env provider (TEI sidecar). TEI is OpenAI-compatible, so reuse
// the existing openai path — no new SDK. A dummy key is fine for a
// keyless self-hosted sidecar.
const endpoint = process.env.EMBEDDING_ENDPOINT?.trim();
const globalModel = process.env.EMBEDDING_MODEL?.trim();
if (endpoint && globalModel) {
const model = createOpenAI({
baseURL: endpoint,
apiKey: process.env.EMBEDDING_API_KEY || 'unused',
}).textEmbeddingModel(globalModel);
const queryPrefix = process.env.EMBEDDING_QUERY_PREFIX ?? '';
const docPrefix = process.env.EMBEDDING_DOC_PREFIX ?? '';
const dimRaw = Number(process.env.EMBEDDING_DIMENSIONS);
const dimensions = Number.isFinite(dimRaw) && dimRaw > 0 ? dimRaw : null;
return {
model,
queryPrefix,
docPrefix,
fingerprint: computeEmbeddingFingerprint({
modelId: globalModel,
revision: process.env.EMBEDDING_REVISION ?? '',
queryPrefix,
docPrefix,
dimensions,
}),
};
}
// Neither resolved -> drives semantic.reason=no-provider.
throw new AiEmbeddingNotConfiguredException();
}
/**
* #530: embed a SEARCH QUERY. Resolves the provider, prepends its query prefix,
* and embeds the single value under its OWN short timeout
* (SEARCH_EMBED_TIMEOUT_MS, default 800ms) NOT the long batch-indexing
* timeout so a slow/hung sidecar degrades search fast. Throws on
* timeout/error (the caller degrades to the lexical-only path). Returns the
* vector plus the active fingerprint used to filter candidate rows.
*/
async embedQuery(
workspaceId: string,
text: string,
): Promise<{ vector: number[]; fingerprint: string }> {
const provider = await this.resolveEmbeddingProvider(workspaceId);
const [vector] = await this.embedWithModel(
provider.model,
workspaceId,
[provider.queryPrefix + text],
AiService.searchEmbedTimeoutMs(),
);
return { vector, fingerprint: provider.fingerprint };
}
/**
* Embed values with an EXPLICIT model, bounded by `timeoutMs` (default: the
* batch-indexing timeout). Shared core of embedTexts / embedQuery: a slow/hung
* embeddings endpoint must fail loudly instead of blocking forever. The single
* signal caps the WHOLE call, including the SDK's internal retries/backoff
* (embedMany defaults to maxRetries: 2).
*/
async embedWithModel(
model: EmbeddingModel,
workspaceId: string,
texts: string[],
timeoutMs: number = AiService.embeddingTimeoutMs(),
): Promise<number[][]> {
if (texts.length === 0) return [];
const signal = AbortSignal.timeout(timeoutMs);
try {
const { embeddings } = await embedMany({
@@ -391,8 +536,8 @@ export class AiService {
if (signal.aborted && abortLike) {
throw new Error(
`Embedding request timed out after ${timeoutMs}ms ` +
`(workspace ${workspaceId}, ${texts.length} chunk(s)). ` +
`Increase AI_EMBEDDING_TIMEOUT_MS or check the embeddings endpoint.`,
`(workspace ${workspaceId}, ${texts.length} value(s)). ` +
`Increase the embedding timeout or check the embeddings endpoint.`,
);
}
throw err;
@@ -408,6 +553,16 @@ export class AiService {
return Number.isFinite(raw) && raw > 0 ? raw : 120_000;
}
/**
* #530: per-call timeout for the interactive SEARCH query embed. Much shorter
* than the batch-indexing timeout (a search request cannot wait 2 minutes on
* the sidecar). Configurable via SEARCH_EMBED_TIMEOUT_MS; default 800ms.
*/
private static searchEmbedTimeoutMs(): number {
const raw = Number(process.env.SEARCH_EMBED_TIMEOUT_MS);
return Number.isFinite(raw) && raw > 0 ? raw : 800;
}
// Build a tiny valid WAV (mono, 16-bit PCM, 16 kHz, ~1s of silence), used only
// as a connectivity probe for the STT endpoint in testConnection.
private static silentWavProbe(): Uint8Array {
@@ -70,6 +70,23 @@ export class EnvironmentService {
return this.configService.get<string>('JWT_TOKEN_EXPIRES_IN', '90d');
}
// Kill-switch for the agent API-key feature. Default ON (unset -> enabled): a
// deploy that never sets the variable must NOT silently kill every agent. The
// parse is STRICT — the value is validated by environment.validation to be
// exactly 'true' or 'false' (or absent), so a typo like `=0`/`=off`/`=False`
// fails at boot rather than being read as "enabled" (which would leave an
// operator who yanked the switch during an incident with it still on). When
// OFF: validate() denies every api-key token and the issuance endpoints 404.
isApiKeysEnabled(): boolean {
return this.configService.get<string>('API_KEYS_ENABLED', 'true') !== 'false';
}
// Raw value (or undefined) for the boot log, so the state after each deploy is
// verifiable in container logs: `API keys: ENABLED/DISABLED (API_KEYS_ENABLED=...)`.
getApiKeysEnabledRaw(): string | undefined {
return this.configService.get<string>('API_KEYS_ENABLED');
}
getCookieExpiresIn(): Date {
const expiresInStr = this.getJwtTokenExpiresIn();
let msUntilExpiry: number;
@@ -103,6 +103,15 @@ export class EnvironmentVariables {
@IsString()
TYPESENSE_LOCALE: string;
// Agent API-key kill-switch. Optional (absent -> default ON). STRICT: only the
// literals 'true'/'false' are accepted, so `=0`/`=off`/`=False` fail at boot
// instead of being silently read as "enabled" — the switch must actually flip
// when an operator flips it. See EnvironmentService.isApiKeysEnabled.
@IsOptional()
@IsIn(['true', 'false'])
@IsString()
API_KEYS_ENABLED: string;
@IsOptional()
@ValidateIf((obj) => obj.AI_DRIVER)
@IsIn(['openai', 'openai-compatible', 'gemini', 'ollama'])
@@ -1,7 +1,10 @@
import { randomUUID } from 'node:crypto';
import { Kysely, sql } from 'kysely';
import { Logger } from '@nestjs/common';
import { SearchService } from 'src/core/search/search.service';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PageEmbeddingRepo } from '@docmost/db/repos/ai-chat/page-embedding.repo';
import { AiEmbeddingNotConfiguredException } from 'src/integrations/ai/ai-embedding-not-configured.exception';
import {
getTestDb,
destroyTestDb,
@@ -53,10 +56,19 @@ describe('SearchService #529 lexical overhaul [integration]', () => {
// Service wired to the real DB + real PageRepo (recursive descendants) with
// stubbed space-membership + permission repos so a test controls scope and the
// permission filter explicitly. `accessibleIds` (when set) is the KEEP list.
// #530: a FAKE AiService whose embedQuery is fully controlled per test. By
// DEFAULT it throws AiEmbeddingNotConfiguredException, so the semantic arm is
// omitted and every Phase-A case runs the BYTE-IDENTICAL lexical path — the
// semantic layer must never change lexical behaviour on degrade. Semantic cases
// pass `embedVector` (a deterministic 384-dim query vector) or `embedThrows`.
function buildService(opts?: {
userSpaceIds?: string[];
accessibleIds?: string[] | null;
filterThrows?: boolean;
embedVector?: number[];
embedFingerprint?: string;
embedThrows?: 'no-provider' | 'degraded';
slowVectorArm?: boolean;
}): SearchService {
const pageRepo = new PageRepo(db as any, null as any, null as any);
const spaceMemberRepo = {
@@ -71,15 +83,86 @@ describe('SearchService #529 lexical overhaul [integration]', () => {
: pageIds;
},
};
const aiService = {
embedQuery: async () => {
if (opts?.embedVector) {
return {
vector: opts.embedVector,
fingerprint: opts.embedFingerprint ?? ACTIVE_FP,
};
}
if (opts?.embedThrows === 'degraded') {
const err = new Error('embedding request timed out after 1ms');
err.name = 'TimeoutError';
throw err;
}
// Default (and explicit 'no-provider'): no embedding provider resolved.
throw new AiEmbeddingNotConfiguredException();
},
};
// Real repo on the migrated test DB — exercises the actual vector-candidate
// SQL (pgvector <=>, fingerprint + dimension filters). When slowVectorArm is
// set, wrap the arm so it sleeps well past the (tiny, test-set) statement
// timeout, forcing a 57014 cancellation to exercise the degrade-to-lexical net.
const realRepo = new PageEmbeddingRepo(db as any);
const pageEmbeddingRepo = opts?.slowVectorArm
? {
vectorCandidateArm: (p: any) => {
const inner = realRepo.vectorCandidateArm(p);
return sql`SELECT * FROM (${inner}) AS slowarm WHERE (SELECT true FROM pg_sleep(0.5))`;
},
}
: realRepo;
return new SearchService(
db as any,
pageRepo as any,
{} as any,
spaceMemberRepo as any,
pagePermissionRepo as any,
aiService as any,
pageEmbeddingRepo as any,
);
}
// Active embedding fingerprint used by planted rows + the fake query embed.
const ACTIVE_FP = 'fp-active-530';
// A deterministic unit-ish 384-dim vector with a single 1 at `concept`. Two
// vectors of the same concept have cosine distance 0; different concepts are
// orthogonal (distance 1), so nearest-neighbour ordering is fully controlled.
function conceptVec(concept: number): number[] {
const v = new Array(384).fill(0);
v[concept % 384] = 1;
return v;
}
// Plant one chunk embedding for a page via the REAL repo (exercises the #530
// fingerprint insert path). Dimension is fixed at 384 to match conceptVec.
async function plantEmbedding(
pageId: string,
vector: number[],
fingerprint: string = ACTIVE_FP,
planSpaceId: string = spaceId,
): Promise<void> {
const repo = new PageEmbeddingRepo(db as any);
await repo.insertChunks([
{
pageId,
workspaceId,
spaceId: planSpaceId,
attachmentId: null,
chunkIndex: 0,
chunkStart: 0,
chunkLength: 1,
content: 'planted chunk',
modelName: 'test-model',
modelDimensions: 384,
fingerprint,
embedding: vector,
},
]);
}
const search = (service: SearchService, params: any) =>
service.searchPage(params, { userId: 'u-1', workspaceId }) as any;
@@ -609,4 +692,324 @@ describe('SearchService #529 lexical overhaul [integration]', () => {
expect(hit2).toBeDefined();
expect(hit2.path).toEqual([]);
});
// === #530 Phase B (PR-1): semantic fusion ==================================
// Deterministic fake embedder (no live model) + real PageEmbeddingRepo on the
// migrated DB with planted vectors + the active fingerprint, so cosine ordering
// is fully controlled. Each case runs in its OWN space so planted rows never
// leak across tests.
describe('#530 semantic fusion', () => {
// 1. Vector-only hit: nearest to a page that has NO lexical match for the
// query term — it appears via the vector arm, and a pure-lexical run does
// not return it. Mutation (a): drop the vector arm -> this reddens.
it('#530-1 a vector-only hit appears; a pure-lexical run would not return it', async () => {
const s = (await createSpace(db, workspaceId)).id;
const page = await insertPage({
title: 'нейтральный вект заголовок',
textContent: 'содержимое без искомого термина',
spaceId: s,
});
await plantEmbedding(page, conceptVec(1), ACTIVE_FP, s);
const svc = buildService({ embedVector: conceptVec(1), userSpaceIds: [s] });
const res = await search(svc, { query: 'квазонимикс', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(page);
expect(res.semantic.available).toBe(true);
expect(res.semantic.state).toBe('full');
// Pure-lexical (default degrade): the vector-only page is absent.
const lex = buildService({ userSpaceIds: [s] });
const res2 = await search(lex, { query: 'квазонимикс', spaceId: s });
expect(res2.items.map((i: any) => i.id)).not.toContain(page);
expect(res2.semantic.available).toBe(false);
});
// 2. Sidecar down: embedQuery throws -> results = the lexical set; semantic
// unavailable; the fixed 'search.semantic.degraded' event is logged.
it('#530-2 sidecar-down degrades to lexical and logs search.semantic.degraded', async () => {
const s = (await createSpace(db, workspaceId)).id;
const lexHit = await insertPage({
title: 'семдвамаркер лексический',
textContent: 'обычный текст',
spaceId: s,
});
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as any);
try {
const svc = buildService({ embedThrows: 'degraded', userSpaceIds: [s] });
const res = await search(svc, { query: 'семдвамаркер', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(lexHit);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('degraded');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('search.semantic.degraded'),
);
} finally {
warnSpy.mockRestore();
}
});
// 3. No provider: resolveEmbeddingProvider yields none -> lexical results,
// reason no-provider, no 500.
it('#530-3 no-provider yields lexical results with reason no-provider', async () => {
const s = (await createSpace(db, workspaceId)).id;
const hit = await insertPage({
title: 'семтримаркер тема',
textContent: 'текст',
spaceId: s,
});
const svc = buildService({ embedThrows: 'no-provider', userSpaceIds: [s] });
const res = await search(svc, { query: 'семтримаркер', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(hit);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('no-provider');
});
// 4. Permission over the union: a restricted vector-only hit is dropped from
// items AND total; with the permission filter throwing the call rejects
// (fail-closed, never fail-open). Mutation (b): move filterAccessiblePageIds
// inside the semantic try/catch -> the reject assertion reddens.
it('#530-4 permission filters a vector-only hit from items AND total (fail-closed)', async () => {
const s = (await createSpace(db, workspaceId)).id;
const visibleLex = await insertPage({
title: 'семчетмаркер видимый',
textContent: 'доступный текст',
spaceId: s,
});
const hiddenVec = await insertPage({
title: 'скрытая вект страница',
textContent: 'содержимое без искомого термина',
spaceId: s,
});
await plantEmbedding(hiddenVec, conceptVec(4), ACTIVE_FP, s);
// Query hits visibleLex lexically; hiddenVec only via the vector arm.
const svc = buildService({
embedVector: conceptVec(4),
accessibleIds: [visibleLex],
userSpaceIds: [s],
});
const res = await search(svc, { query: 'семчетмаркер', spaceId: s });
const ids = res.items.map((i: any) => i.id);
expect(ids).toContain(visibleLex);
expect(ids).not.toContain(hiddenVec);
// The hidden vector hit does not leak into total either.
expect(res.total).toBe(1);
expect(ids).toHaveLength(res.total);
// A permission-query error PROPAGATES (never a fail-open empty result).
const boom = buildService({
embedVector: conceptVec(4),
filterThrows: true,
userSpaceIds: [s],
});
await expect(
search(boom, { query: 'семчетмаркер', spaceId: s }),
).rejects.toThrow(/permission query failed/);
});
// 5. Hung sidecar under a short embed timeout: graceful degrade (never a 500).
it('#530-5 a hung sidecar under a short timeout degrades gracefully', async () => {
const s = (await createSpace(db, workspaceId)).id;
const hit = await insertPage({
title: 'семпятьмаркер тема',
textContent: 'текст',
spaceId: s,
});
process.env.SEARCH_EMBED_TIMEOUT_MS = '1';
try {
const svc = buildService({ embedThrows: 'degraded', userSpaceIds: [s] });
const res = await search(svc, { query: 'семпятьмаркер', spaceId: s });
expect(res.items.map((i: any) => i.id)).toContain(hit);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('degraded');
} finally {
delete process.env.SEARCH_EMBED_TIMEOUT_MS;
}
});
// 6. A page matched BOTH lexically and by vector is fused (de-duped), not
// returned twice — the agg CTE collapses it to one row.
it('#530-6 a page matched lexically AND by vector appears once', async () => {
const s = (await createSpace(db, workspaceId)).id;
const both = await insertPage({
title: 'семшестьмаркер общий',
textContent: 'и лексика и вектор',
spaceId: s,
});
await plantEmbedding(both, conceptVec(6), ACTIVE_FP, s);
const svc = buildService({ embedVector: conceptVec(6), userSpaceIds: [s] });
const res = await search(svc, { query: 'семшестьмаркер', spaceId: s });
const ids = res.items.map((i: any) => i.id);
expect(ids.filter((id: string) => id === both)).toHaveLength(1);
expect(res.total).toBe(1);
});
// 7. Fingerprint isolation: a planted row under a DIFFERENT fingerprint is not
// a vector candidate for the active-fingerprint query (guards mixing
// generations). It only appears if it also matches lexically (it does not).
it('#530-7 a stale-fingerprint vector row is not fused into results', async () => {
const s = (await createSpace(db, workspaceId)).id;
const stale = await insertPage({
title: 'нейтральный семь заголовок',
textContent: 'без искомого термина совсем',
spaceId: s,
});
// Same concept vector as the query, but an OLD generation fingerprint.
await plantEmbedding(stale, conceptVec(7), 'fp-stale-old', s);
const svc = buildService({ embedVector: conceptVec(7), userSpaceIds: [s] });
const res = await search(svc, { query: 'квазонимиксseven', spaceId: s });
expect(res.items.map((i: any) => i.id)).not.toContain(stale);
});
// 8. Stale embedding space_id (moved page): a page currently in space B whose
// embedding rows still carry space A (movePageToSpace does NOT reindex) is
// STILL a vector hit when searching B — space scoping is enforced only by
// the pages join (pages.space_id, always current), NOT by a stale
// page_embeddings.space_id. Guards against re-adding that predicate (which
// would wrongly drop the moved page's vector-only hit).
it('#530-8 a vector hit with a STALE embedding space_id (moved page) is still returned', async () => {
const newSpace = (await createSpace(db, workspaceId)).id;
const oldSpace = (await createSpace(db, workspaceId)).id;
// The page currently lives in newSpace (as after a move into newSpace).
const page = await insertPage({
title: 'перемещённая вект страница',
textContent: 'содержимое без искомого термина',
spaceId: newSpace,
});
// Its embedding was stamped with the OLD space and never reindexed.
await plantEmbedding(page, conceptVec(8), ACTIVE_FP, oldSpace);
const svc = buildService({
embedVector: conceptVec(8),
userSpaceIds: [newSpace],
});
const res = await search(svc, {
query: 'квазонимиксeight',
spaceId: newSpace,
});
// Survives despite page_embeddings.space_id (oldSpace) != pages.space_id.
expect(res.items.map((i: any) => i.id)).toContain(page);
expect(res.semantic.available).toBe(true);
});
// 9. Statement-timeout safety net: a pathologically slow vector scan is
// cancelled (SQLSTATE 57014) and search degrades to lexical-only — the
// lexical hit survives, the vector-only hit vanishes, no 500/hang, and the
// degraded event is logged. Mutation: make the fallback re-throw instead of
// degrading -> this test reds (the call rejects with 57014).
it('#530-9 a vector-query timeout degrades to lexical (57014), never 500', async () => {
const s = (await createSpace(db, workspaceId)).id;
const lexHit = await insertPage({
title: 'семдевятьмаркер лексический',
textContent: 'обычный текст',
spaceId: s,
});
const vecOnly = await insertPage({
title: 'нейтральный девять заголовок',
textContent: 'без искомого термина',
spaceId: s,
});
await plantEmbedding(vecOnly, conceptVec(9), ACTIVE_FP, s);
const warnSpy = jest
.spyOn(Logger.prototype, 'warn')
.mockImplementation(() => undefined as any);
// Tiny per-statement timeout so the 0.5s sleeping arm is cancelled.
process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS = '50';
try {
const svc = buildService({
embedVector: conceptVec(9),
slowVectorArm: true,
userSpaceIds: [s],
});
const res = await search(svc, { query: 'семдевятьмаркер', spaceId: s });
// Lexical result survives; the vector-only page does NOT (arm cancelled).
const ids = res.items.map((i: any) => i.id);
expect(ids).toContain(lexHit);
expect(ids).not.toContain(vecOnly);
expect(res.semantic.available).toBe(false);
expect(res.semantic.reason).toBe('degraded');
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('search.semantic.degraded'),
);
} finally {
delete process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS;
warnSpy.mockRestore();
}
});
// 10. SET LOCAL scoping: the search's statement timeout must NOT leak onto a
// later query on the same pooled connection. After a search that set a
// tiny timeout, a deliberately-slow standalone query still completes.
it('#530-10 the per-query statement timeout does not leak to later queries', async () => {
const s = (await createSpace(db, workspaceId)).id;
await insertPage({
title: 'семдесятьмаркер тема',
textContent: 'текст',
spaceId: s,
});
process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS = '50';
try {
const svc = buildService({
embedVector: conceptVec(10),
slowVectorArm: true,
userSpaceIds: [s],
});
// This search trips + resets the local timeout (via SET LOCAL semantics).
await search(svc, { query: 'семдесятьмаркер', spaceId: s });
// A subsequent query that sleeps 200ms must NOT be cancelled — proving the
// 50ms search timeout did not persist on the connection (it would raise
// 57014 if it had leaked). SET LOCAL auto-resets at the search tx end.
await expect(sql`SELECT pg_sleep(0.2)`.execute(db)).resolves.toBeDefined();
} finally {
delete process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS;
}
});
// 11. COMMIT-path no-leak (locks is_local=true). #530-10 only covers the
// ROLLBACK path (a timed-out search rolls back — a plain session SET would
// ALSO be undone on rollback, so it would NOT catch is_local true->false).
// Here a NORMAL, FAST semantic search COMMITs its transaction; a leaked
// session-level statement_timeout would then persist on the pooled
// connection and cancel later queries. We prove it does NOT: after the
// committing search (bounded at a small 100ms), a 300ms query succeeds.
// Because postgres.js pools, we run several sequential slow queries so at
// least one reuses the connection the committed search ran on (which is
// where a leaked timeout would live); ALL must succeed. Mutation: flip
// is_local true->false -> the committed 100ms timeout leaks and the
// follow-up pg_sleep is cancelled (57014), reddening this test.
it('#530-11 a COMMITTED search does not leak its statement timeout (is_local)', async () => {
const s = (await createSpace(db, workspaceId)).id;
const page = await insertPage({
title: 'семодиннадцать вект страница',
textContent: 'содержимое без искомого термина',
spaceId: s,
});
await plantEmbedding(page, conceptVec(11), ACTIVE_FP, s);
// Small bound: the fast vector arm still completes (<100ms) so the search
// COMMITs, but small enough that a LEAKED timeout would cancel the 300ms
// follow-ups below.
process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS = '100';
try {
const svc = buildService({
embedVector: conceptVec(11),
userSpaceIds: [s],
});
const res = await search(svc, { query: 'семодиннадцать', spaceId: s });
// The (committing) vector path actually ran.
expect(res.semantic.available).toBe(true);
// Each 300ms query must SUCCEED — a leaked 100ms timeout would cancel the
// one that reuses the committed search's connection. 8 sweeps cover the
// whole pool (max 5 conns), so a leak on any connection is caught.
for (let i = 0; i < 8; i++) {
await expect(
sql`SELECT pg_sleep(0.3)`.execute(db),
).resolves.toBeDefined();
}
} finally {
delete process.env.SEARCH_VECTOR_STATEMENT_TIMEOUT_MS;
}
});
});
});
+41
View File
@@ -4,11 +4,26 @@ services:
depends_on:
- db
- redis
- embeddings
environment:
APP_URL: 'http://localhost:3000'
APP_SECRET: 'REPLACE_WITH_LONG_SECRET'
DATABASE_URL: 'postgresql://docmost:STRONG_DB_PASSWORD@db:5432/docmost'
REDIS_URL: 'redis://redis:6379'
# #530 semantic search: the GLOBAL embedding provider (the `embeddings` TEI
# sidecar below). A workspace that configures its own embedding provider
# overrides this. TEI is OpenAI-compatible, so the endpoint is /v1.
EMBEDDING_ENDPOINT: 'http://embeddings:80/v1'
EMBEDDING_MODEL: 'intfloat/multilingual-e5-small'
EMBEDDING_API_KEY: 'unused'
EMBEDDING_DIMENSIONS: '384'
# MUST match the --revision the sidecar pins (see EMBEDDING_REVISION in
# .env). A revision bump changes the embedding fingerprint (PR-2 swaps
# generations); keep the two in lockstep.
EMBEDDING_REVISION: '${EMBEDDING_REVISION}'
# e5 models require these input prefixes; empty them for a non-e5 model.
EMBEDDING_QUERY_PREFIX: 'query: '
EMBEDDING_DOC_PREFIX: 'passage: '
ports:
- "3000:3000"
restart: unless-stopped
@@ -39,7 +54,33 @@ services:
volumes:
- redis_data:/data
# #530 Text Embeddings Inference (TEI) sidecar — the GLOBAL embedding provider
# for semantic search. OpenAI-compatible, reached only on the internal network
# (no published port). The model + revision are PINNED so the embedding
# fingerprint is stable; set EMBEDDING_REVISION to a real commit sha in .env.
embeddings:
image: ghcr.io/huggingface/text-embeddings-inference:cpu-1.9
command:
- "--model-id"
- "intfloat/multilingual-e5-small"
- "--revision"
- "${EMBEDDING_REVISION}"
restart: unless-stopped
# Cache downloaded model weights so a restart does not re-download them.
volumes:
- tei-models:/data
# GET /health is TEI's readiness probe. (Drop this block if your TEI image
# variant ships without curl.)
healthcheck:
test: ["CMD-SHELL", "curl -fsS http://localhost:80/health || exit 1"]
interval: 30s
timeout: 5s
retries: 5
# Model download on first boot can be slow; don't flap as unhealthy meanwhile.
start_period: 120s
volumes:
docmost:
db_data:
redis_data:
tei-models: