Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbac8e6976 |
@@ -198,42 +198,6 @@ 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
|
||||
|
||||
@@ -1,362 +0,0 @@
|
||||
/**
|
||||
* PageHistoryModal — редизайн окна «Page history».
|
||||
* Mantine v7. Light/dark. Полноразмерное модальное окно.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ЧТО ЭТО
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* Окно истории версий страницы. Слева — панель навигации: мини-календарь
|
||||
* (heatmap: яркость дня = число ревизий, обводка = выбранный день) + плотный
|
||||
* список ревизий (одна ревизия = одна строка). Справа — реально отрендеренная
|
||||
* версия страницы с подсветкой изменений. Все контролы — в одну строку шапки.
|
||||
*
|
||||
* Ключевые решения:
|
||||
* - Плотный список: одна ревизия на строку (аватар · время · автор · [SAVED]).
|
||||
* Бейдж показывается ТОЛЬКО у сохранённых версий; всё остальное — autosave.
|
||||
* - Агентские ревизии визуально отличаются: квадратный глиф роли (К/Ф) вместо
|
||||
* круглого аватара пользователя + «via <кто запустил>».
|
||||
* - Календарь объединён со списком в одной панели (мини-календарь = date jumper).
|
||||
* - Highlight changes + навигация по изменениям (N of M, ↑/↓) вынесены в шапку.
|
||||
* - Текущая версия помечена; Restore для неё недоступен.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* УСТАНОВКА / ИСПОЛЬЗОВАНИЕ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* import { PageHistoryModal, Revision } from './PageHistoryModal';
|
||||
*
|
||||
* <PageHistoryModal
|
||||
* opened={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* revisions={revisions} // Revision[]
|
||||
* selectedId={selId}
|
||||
* onSelect={setSelId} // клик по ревизии → рендер справа
|
||||
* renderVersion={(rev) => <ArticleView versionId={rev.id} />} // ваш рендер страницы
|
||||
* highlightChanges={hl}
|
||||
* onToggleHighlight={setHl}
|
||||
* changeNav={{ index: 1, total: 3, onPrev, onNext }} // навигация по диффу
|
||||
* onlySaved={onlySaved}
|
||||
* onToggleOnlySaved={setOnlySaved}
|
||||
* onRestore={(rev) => api.restore(rev.id)} // async; текущая версия → disabled
|
||||
* />
|
||||
*
|
||||
* Требует MantineProvider на корне приложения (defaultColorScheme="auto" для тем).
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* ФОРМАТ ДАННЫХ
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* interface Revision {
|
||||
* id: string;
|
||||
* at: Date | string; // время ревизии; форматируется вызывающим/util-ом
|
||||
* dayGroup: string; // 'Today' | 'Yesterday' | 'Mon 12 Jul' — заголовок группы
|
||||
* saved: boolean; // true → бейдж SAVED; false → autosave (без бейджа)
|
||||
* author: { name: string } & (
|
||||
* | { kind: 'human' }
|
||||
* | { kind: 'agent'; role: string; triggeredBy: string } // роль + кто запустил
|
||||
* );
|
||||
* isCurrent?: boolean; // текущая (последняя) версия — Restore недоступен
|
||||
* }
|
||||
*
|
||||
* Ревизии приходят плоским массивом; группировка по dayGroup — на клиенте.
|
||||
* Никаких изменений API не требуется: агентское авторство берётся из author.kind.
|
||||
*
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* СОСТОЯНИЯ (нарисованы/поддержаны)
|
||||
* ─────────────────────────────────────────────────────────────────────────
|
||||
* - Ревизия: обычная / hover / выбранная / текущая / агентская / человеческая.
|
||||
* - Restore: default / disabled (выбрана текущая) / loading (спиннер после клика).
|
||||
* - Highlight changes: on/off; при off навигация по изменениям неактивна.
|
||||
* - Пустая история: одна версия / нет ревизий → EmptyState.
|
||||
* - Тёмная тема: через токены Mantine (useMantineColorScheme, var(--mantine-*)).
|
||||
*/
|
||||
import { useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal, Box, Group, Stack, Text, Switch, Avatar, Badge, Button, ActionIcon,
|
||||
ScrollArea, useMantineTheme, useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
|
||||
/* ─────────────────────────── Типы ─────────────────────────── */
|
||||
|
||||
export type Author =
|
||||
| { name: string; kind: 'human' }
|
||||
| { name: string; kind: 'agent'; role: string; triggeredBy: string };
|
||||
|
||||
export interface Revision {
|
||||
id: string;
|
||||
at: Date | string;
|
||||
atLabel: string; // предформатированное «5:35AM»
|
||||
dayGroup: string; // «Today» | «Yesterday» | «Mon 12 Jul»
|
||||
saved: boolean;
|
||||
author: Author;
|
||||
isCurrent?: boolean;
|
||||
}
|
||||
|
||||
export interface CalendarDay {
|
||||
label: string; // «12»
|
||||
inMonth: boolean;
|
||||
count: number; // число ревизий за день (для heatmap)
|
||||
selected?: boolean;
|
||||
date: Date | string;
|
||||
}
|
||||
|
||||
export interface PageHistoryModalProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
revisions: Revision[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string) => void;
|
||||
renderVersion: (rev: Revision | null) => React.ReactNode;
|
||||
highlightChanges: boolean;
|
||||
onToggleHighlight: (v: boolean) => void;
|
||||
changeNav?: { index: number; total: number; onPrev: () => void; onNext: () => void };
|
||||
onlySaved: boolean;
|
||||
onToggleOnlySaved: (v: boolean) => void;
|
||||
onRestore: (rev: Revision) => Promise<void>;
|
||||
/** Ячейки текущего месяца календаря + управление. Необязательно — без него панель = только список. */
|
||||
calendar?: {
|
||||
monthLabel: string; // «July 2025»
|
||||
weekdays: string[]; // ['Mon',…,'Sun']
|
||||
days: CalendarDay[]; // обычно 35/42 ячейки
|
||||
onPrevMonth: () => void;
|
||||
onNextMonth: () => void;
|
||||
onToday: () => void;
|
||||
onPickDay: (d: CalendarDay) => void;
|
||||
};
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Утилиты представления ─────────────────────────── */
|
||||
|
||||
const USER_PALETTE = ['#495057', '#e8590c', '#1c7ed6', '#7048e8', '#0ca678'];
|
||||
function userColor(name: string) {
|
||||
let h = 0;
|
||||
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
|
||||
return USER_PALETTE[h % USER_PALETTE.length];
|
||||
}
|
||||
const AGENT_GRAD: Record<string, string> = {
|
||||
'Корректор': 'linear-gradient(135deg,#20c997,#12b886)',
|
||||
'Фактчекер': 'linear-gradient(135deg,#4c6ef5,#7048e8)',
|
||||
};
|
||||
function agentGrad(role: string) { return AGENT_GRAD[role] ?? 'linear-gradient(135deg,#868e96,#495057)'; }
|
||||
|
||||
/** heatmap: число ревизий → фон/текст ячейки календаря. */
|
||||
function heat(n: number, dark: boolean) {
|
||||
if (n === 0) return { bg: 'transparent', fg: dark ? '#5c5f66' : '#adb5bd' };
|
||||
if (n <= 2) return { bg: dark ? 'rgba(34,139,230,.20)' : '#e7f0ff', fg: dark ? '#74c0fc' : '#1971c2' };
|
||||
if (n <= 4) return { bg: dark ? 'rgba(34,139,230,.45)' : '#a5c8ff', fg: dark ? '#dbeafe' : '#0b3d91' };
|
||||
return { bg: '#4c8dff', fg: '#ffffff' };
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Строка ревизии ─────────────────────────── */
|
||||
|
||||
function RevisionRow({ rev, selected, onSelect }: { rev: Revision; selected: boolean; onSelect: () => void }) {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
const a = rev.author;
|
||||
const isAgent = a.kind === 'agent';
|
||||
|
||||
return (
|
||||
<Group
|
||||
gap={8} wrap="nowrap" h={28} px="12px 14px" m="1px 6px"
|
||||
onClick={onSelect}
|
||||
style={{
|
||||
cursor: 'pointer', borderRadius: 7,
|
||||
background: selected ? (dark ? 'rgba(34,139,230,.14)' : '#eef4ff')
|
||||
: isAgent ? (dark ? 'rgba(112,72,232,.06)' : '#fbfaff') : undefined,
|
||||
}}
|
||||
>
|
||||
{/* аватар/глиф */}
|
||||
{isAgent ? (
|
||||
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: 4, background: agentGrad(a.role), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
|
||||
{a.role[0]}
|
||||
</Box>
|
||||
) : (
|
||||
<Box style={{ flex: 'none', width: 16, height: 16, borderRadius: '50%', background: userColor(a.name), display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', font: '700 8px system-ui' }}>
|
||||
{a.name[0].toUpperCase()}
|
||||
</Box>
|
||||
)}
|
||||
{/* время */}
|
||||
<Text fz={12.5} fw={rev.isCurrent ? 600 : 500} c={rev.isCurrent ? undefined : 'dimmed'} style={{ flex: 'none', minWidth: 58 }}>
|
||||
{rev.atLabel}
|
||||
</Text>
|
||||
{/* автор + via */}
|
||||
<Group gap={4} wrap="nowrap" style={{ flex: 1, minWidth: 0, alignItems: 'baseline', overflow: 'hidden' }}>
|
||||
<Text fz={12} fw={isAgent ? 600 : 400} c={isAgent ? undefined : 'dimmed'} truncate style={{ flex: 'none', maxWidth: 100 }}>
|
||||
{isAgent ? a.role : a.name}
|
||||
</Text>
|
||||
{isAgent && <Text fz={11} c="dimmed" truncate>· via {a.triggeredBy}</Text>}
|
||||
</Group>
|
||||
{/* бейдж — только SAVED */}
|
||||
{rev.saved && <Badge size="sm" radius="sm" variant="light" color="blue">SAVED</Badge>}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Мини-календарь ─────────────────────────── */
|
||||
|
||||
function MiniCalendar({ cal }: { cal: NonNullable<PageHistoryModalProps['calendar']> }) {
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const dark = colorScheme === 'dark';
|
||||
return (
|
||||
<Box p="10px 12px 8px" style={(t) => ({ borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Group gap={6} mb={6}>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onPrevMonth}>‹</ActionIcon>
|
||||
<Text fz={12} fw={600}>{cal.monthLabel}</Text>
|
||||
<ActionIcon variant="subtle" color="gray" size="sm" onClick={cal.onNextMonth}>›</ActionIcon>
|
||||
<Box style={{ flex: 1 }} />
|
||||
<Button variant="subtle" size="compact-xs" onClick={cal.onToday}>Today</Button>
|
||||
</Group>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2, marginBottom: 2 }}>
|
||||
{cal.weekdays.map((w) => (
|
||||
<Text key={w} ta="center" fz={8.5} fw={600} c="dimmed">{w}</Text>
|
||||
))}
|
||||
</Box>
|
||||
<Box style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)', gap: 2 }}>
|
||||
{cal.days.map((d, i) => {
|
||||
const h = heat(d.count, dark);
|
||||
return (
|
||||
<Box
|
||||
key={i} onClick={() => cal.onPickDay(d)}
|
||||
style={{
|
||||
position: 'relative', height: 26, borderRadius: 6, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
background: d.inMonth ? h.bg : 'transparent',
|
||||
boxShadow: d.selected ? `inset 0 0 0 2px ${dark ? '#f1f3f5' : '#1a1b1e'}` : undefined,
|
||||
}}
|
||||
>
|
||||
<Text fz={10.5} fw={d.selected ? 700 : 500} style={{ color: d.inMonth ? h.fg : (dark ? '#3a3d42' : '#dee2e6') }}>
|
||||
{d.label}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{/* легенда heatmap */}
|
||||
<Group gap={6} mt={8} align="center">
|
||||
<Text fz={9.5} c="dimmed">fewer</Text>
|
||||
{['#e7f0ff', '#a5c8ff', '#4c8dff'].map((c) => (
|
||||
<Box key={c} style={{ width: 14, height: 10, borderRadius: 2, background: c }} />
|
||||
))}
|
||||
<Text fz={9.5} c="dimmed">more revisions</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Окно ─────────────────────────── */
|
||||
|
||||
export function PageHistoryModal(props: PageHistoryModalProps) {
|
||||
const {
|
||||
opened, onClose, revisions, selectedId, onSelect, renderVersion,
|
||||
highlightChanges, onToggleHighlight, changeNav, onlySaved, onToggleOnlySaved,
|
||||
onRestore, calendar,
|
||||
} = props;
|
||||
const [restoring, setRestoring] = useState(false);
|
||||
|
||||
const selected = revisions.find((r) => r.id === selectedId) ?? null;
|
||||
const isEmpty = revisions.length <= 1;
|
||||
|
||||
// группировка по dayGroup, с фильтром Only saved
|
||||
const groups = useMemo(() => {
|
||||
const list = onlySaved ? revisions.filter((r) => r.saved) : revisions;
|
||||
const map: { head: string; items: Revision[] }[] = [];
|
||||
for (const r of list) {
|
||||
let g = map.find((x) => x.head === r.dayGroup);
|
||||
if (!g) { g = { head: r.dayGroup, items: [] }; map.push(g); }
|
||||
g.items.push(r);
|
||||
}
|
||||
return map;
|
||||
}, [revisions, onlySaved]);
|
||||
|
||||
const restore = useCallback(async () => {
|
||||
if (!selected || selected.isCurrent) return;
|
||||
setRestoring(true);
|
||||
try { await onRestore(selected); } finally { setRestoring(false); }
|
||||
}, [selected, onRestore]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened} onClose={onClose} withCloseButton={false}
|
||||
size="80rem" radius="lg" padding={0}
|
||||
styles={{ body: { height: '80vh', maxHeight: 760, display: 'flex', flexDirection: 'column' } }}
|
||||
overlayProps={{ backgroundOpacity: 0.5, blur: 1 }}
|
||||
>
|
||||
{/* ── single-row toolbar ── */}
|
||||
<Group gap={14} h={60} px={16} wrap="nowrap" style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Text fz={16} fw={600}>Page history</Text>
|
||||
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
|
||||
<Stack gap={1} style={{ minWidth: 0 }}>
|
||||
<Text fz={12} fw={600} truncate>{selected ? `${selected.dayGroup} · ${selected.atLabel}` : '—'}</Text>
|
||||
<Text fz={10.5} c="dimmed">selected version</Text>
|
||||
</Stack>
|
||||
|
||||
<Box style={{ flex: 1 }} />
|
||||
|
||||
{/* diff cluster */}
|
||||
<Group gap={2} p={3} wrap="nowrap" style={(t) => ({ background: t.colorScheme === 'dark' ? t.colors.dark[6] : '#f4f6f8', borderRadius: 10 })}>
|
||||
<Button variant="white" size="compact-sm" onClick={() => onToggleHighlight(!highlightChanges)}
|
||||
leftSection={<Switch checked={highlightChanges} onChange={() => {}} size="xs" tabIndex={-1} styles={{ track: { cursor: 'pointer' } }} />}
|
||||
styles={{ root: { boxShadow: '0 1px 2px rgba(0,0,0,.08)' } }}>
|
||||
Highlight changes
|
||||
</Button>
|
||||
{changeNav && (
|
||||
<>
|
||||
<Text fz={12} fw={600} c="dimmed" px={6}>{changeNav.index} / {changeNav.total}</Text>
|
||||
<Box style={{ display: 'flex' }}>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onPrev} style={{ width: 26 }}>↑</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" disabled={!highlightChanges} onClick={changeNav.onNext} style={{ width: 26 }}>↓</ActionIcon>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Box style={(t) => ({ width: 1, height: 22, background: t.colorScheme === 'dark' ? t.colors.dark[4] : t.colors.gray[2] })} />
|
||||
|
||||
<Button color="blue" onClick={restore} loading={restoring} disabled={!selected || selected.isCurrent}>
|
||||
Restore this version
|
||||
</Button>
|
||||
<ActionIcon variant="subtle" color="gray" size="lg" onClick={onClose}>✕</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* ── body ── */}
|
||||
<Box style={{ flex: 1, display: 'flex', minHeight: 0 }}>
|
||||
{/* left: nav panel */}
|
||||
<Stack gap={0} style={(t) => ({ flex: 'none', width: 280, minHeight: 0, borderRight: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}`, background: t.colorScheme === 'dark' ? t.colors.dark[7] : '#fcfcfd' })}>
|
||||
<Group h={44} px={16} style={(t) => ({ flex: 'none', borderBottom: `1px solid ${t.colorScheme === 'dark' ? t.colors.dark[5] : t.colors.gray[1]}` })}>
|
||||
<Switch checked={onlySaved} onChange={(e) => onToggleOnlySaved(e.currentTarget.checked)} size="sm" label="Only saved" labelPosition="right" styles={{ label: { fontSize: 13, fontWeight: 500 } }} />
|
||||
</Group>
|
||||
|
||||
{calendar && <MiniCalendar cal={calendar} />}
|
||||
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
{groups.map((g) => (
|
||||
<Box key={g.head}>
|
||||
<Text px={16} pt={9} pb={5} fz={10.5} fw={600} tt="uppercase" c="dimmed" style={{ letterSpacing: '.05em', position: 'sticky', top: 0, zIndex: 2, background: 'var(--mantine-color-body)' }}>
|
||||
{g.head}
|
||||
</Text>
|
||||
{g.items.map((r) => (
|
||||
<RevisionRow key={r.id} rev={r} selected={r.id === selectedId} onSelect={() => onSelect(r.id)} />
|
||||
))}
|
||||
</Box>
|
||||
))}
|
||||
</ScrollArea>
|
||||
</Stack>
|
||||
|
||||
{/* right: rendered version */}
|
||||
<ScrollArea style={{ flex: 1, minWidth: 0 }} bg="var(--mantine-color-default-hover)">
|
||||
{isEmpty ? (
|
||||
<Stack align="center" justify="center" h="100%" gap={6} p={40}>
|
||||
<Text fw={600} fz={14}>No earlier versions</Text>
|
||||
<Text fz={12.5} c="dimmed" ta="center">У страницы пока одна версия — сравнивать не с чем.</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<Box p="26px 0" maw={660} mx="auto">
|
||||
{renderVersion(selected)}
|
||||
</Box>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageHistoryModal;
|
||||
@@ -346,9 +346,6 @@ roles:
|
||||
□ Working sections ("Log", "Open Questions", "Revision") are moved to an
|
||||
appendix at the end of the document or clearly separated from the report
|
||||
body.
|
||||
□ Once the report is complete, call save_page_version to pin the finished
|
||||
document as a restorable named version (a checkpoint the reader can
|
||||
return to). A repeat call after no further edits is a harmless no-op.
|
||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||
a guess as a fact.
|
||||
autoStart: false
|
||||
@@ -445,7 +442,6 @@ roles:
|
||||
- **The language of the notes = the main language of the call.** Technical terms — in their canonical spelling (usually Latin).
|
||||
- **Don't evaluate the participants** and don't comment on the quality of the discussion.
|
||||
- The output is the notes only, with no preambles or meta-comments, apart from targeted uncertainty marks.
|
||||
- **Pin the result.** Once the notes are complete on the page, call save_page_version to save them as a restorable named version (a checkpoint). Calling it again after no further edits is a harmless no-op.
|
||||
|
||||
## Style example (excerpt)
|
||||
|
||||
|
||||
@@ -345,9 +345,6 @@ roles:
|
||||
□ Working sections («Журнал», «Открытые вопросы», «Ревизия») are moved to
|
||||
an appendix at the end of the document or clearly separated from the
|
||||
report body.
|
||||
□ Once the report is complete, call save_page_version to pin the finished
|
||||
document as a restorable named version (a checkpoint the reader can
|
||||
return to). A repeat call after no further edits is a harmless no-op.
|
||||
Be honest about gaps. If you couldn't find something, say so — don't disguise
|
||||
a guess as a fact.
|
||||
autoStart: false
|
||||
@@ -444,7 +441,6 @@ roles:
|
||||
- **Язык конспекта = основной язык созвона.** Технические термины — в каноническом написании (обычно латиницей).
|
||||
- **Не оценивай участников** и не комментируй качество обсуждения.
|
||||
- На выходе — только конспект, без преамбул и мета-комментариев, кроме точечных пометок неуверенности.
|
||||
- **Зафиксируй результат.** Когда конспект готов на странице, вызови save_page_version, чтобы сохранить его как именованную версию (точку восстановления). Повторный вызов без новых правок — безвредный no-op.
|
||||
|
||||
## Пример стиля (фрагмент)
|
||||
|
||||
|
||||
@@ -33,6 +33,6 @@ bundles:
|
||||
- en
|
||||
roles:
|
||||
- slug: researcher
|
||||
version: 10
|
||||
version: 9
|
||||
- slug: call-summarizer
|
||||
version: 2
|
||||
version: 1
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"1 year": "1 year",
|
||||
"30 days": "30 days",
|
||||
"90 days": "90 days",
|
||||
"A new version is available": "A new version is available",
|
||||
"Account": "Account",
|
||||
"Active": "Active",
|
||||
@@ -13,16 +10,11 @@
|
||||
"Add space members": "Add space members",
|
||||
"Add to favorites": "Add to favorites",
|
||||
"Admin": "Admin",
|
||||
"API key created": "API key created",
|
||||
"API key revoked": "API key revoked",
|
||||
"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?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Are you sure you want to remove this user from the space? The user will lose all access to this space.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Are you sure you want to restore this version? Any changes not versioned will be lost.",
|
||||
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.",
|
||||
"Author": "Author",
|
||||
"Can become members of groups and spaces in workspace": "Can become members of groups and spaces in workspace",
|
||||
"Can create and edit pages in space.": "Can create and edit pages in space.",
|
||||
"Can edit": "Can edit",
|
||||
@@ -41,14 +33,11 @@
|
||||
"Confirm": "Confirm",
|
||||
"Copy as Markdown": "Copy as Markdown",
|
||||
"Copy link": "Copy link",
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.",
|
||||
"Create": "Create",
|
||||
"Create API key": "Create API key",
|
||||
"Create group": "Create group",
|
||||
"Create page": "Create page",
|
||||
"Create space": "Create space",
|
||||
"Create workspace": "Create workspace",
|
||||
"Critical": "Critical",
|
||||
"Current password": "Current password",
|
||||
"Dark": "Dark",
|
||||
"Date": "Date",
|
||||
@@ -66,14 +55,7 @@
|
||||
"e.g Sales": "e.g Sales",
|
||||
"e.g Space for product team": "e.g Space for product team",
|
||||
"e.g Space for sales team to collaborate": "e.g Space for sales team to collaborate",
|
||||
"e.g. CI deploy token": "e.g. CI deploy token",
|
||||
"Edit": "Edit",
|
||||
"Expiring soon": "Expiring soon",
|
||||
"Failed to create API key": "Failed to create API key",
|
||||
"Failed to load API keys.": "Failed to load API keys.",
|
||||
"Failed to revoke API key": "Failed to revoke API key",
|
||||
"Never used": "Never used",
|
||||
"No API keys yet": "No API keys yet",
|
||||
"Read": "Read",
|
||||
"Edit group": "Edit group",
|
||||
"Email": "Email",
|
||||
@@ -126,7 +108,6 @@
|
||||
"Link copied": "Link copied",
|
||||
"Login": "Login",
|
||||
"Logout": "Logout",
|
||||
"Major": "Major",
|
||||
"Manage Group": "Manage Group",
|
||||
"Manage members": "Manage members",
|
||||
"member": "member",
|
||||
@@ -153,8 +134,6 @@
|
||||
"page": "page",
|
||||
"Page deleted successfully": "Page deleted successfully",
|
||||
"Page history": "Page history",
|
||||
"Revoke API key": "Revoke API key",
|
||||
"Revoke {{name}}": "Revoke {{name}}",
|
||||
"Select version": "Select version",
|
||||
"Highlight changes": "Highlight changes",
|
||||
"Page import is in progress. Please do not close this tab.": "Page import is in progress. Please do not close this tab.",
|
||||
@@ -210,9 +189,6 @@
|
||||
"Template": "Template",
|
||||
"Templates": "Templates",
|
||||
"Theme": "Theme",
|
||||
"This key expires within 30 days": "This key expires within 30 days",
|
||||
"This key has expired": "This key has expired",
|
||||
"This key never expires": "This key never expires",
|
||||
"To change your email, you have to enter your password and new email.": "To change your email, you have to enter your password and new email.",
|
||||
"Toggle full page width": "Toggle full page width",
|
||||
"Unable to import pages. Please try again.": "Unable to import pages. Please try again.",
|
||||
@@ -220,7 +196,6 @@
|
||||
"Untitled": "Untitled",
|
||||
"Updated successfully": "Updated successfully",
|
||||
"User": "User",
|
||||
"Within the last hour": "Within the last hour",
|
||||
"Workspace": "Workspace",
|
||||
"Workspace Name": "Workspace Name",
|
||||
"Workspace settings": "Workspace settings",
|
||||
@@ -451,12 +426,9 @@
|
||||
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
|
||||
"Write...": "Write...",
|
||||
"Column count": "Column count",
|
||||
"Your personal API keys.": "Your personal API keys.",
|
||||
"{{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",
|
||||
@@ -1468,7 +1440,6 @@
|
||||
"agent: {{value}}": "agent: {{value}}",
|
||||
"Work": "Work",
|
||||
"Agent": "Agent",
|
||||
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}}h {{minutes}}m",
|
||||
"≈ {{hours}}h": "≈ {{hours}}h",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}}m",
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
{
|
||||
"1 year": "1 год",
|
||||
"30 days": "30 дней",
|
||||
"90 days": "90 дней",
|
||||
"A new version is available": "Доступна новая версия",
|
||||
"Account": "Аккаунт",
|
||||
"Active": "Активный",
|
||||
@@ -13,16 +10,11 @@
|
||||
"Add space members": "Добавить участников пространства",
|
||||
"Add to favorites": "Добавить в избранное",
|
||||
"Admin": "Администратор",
|
||||
"API key created": "API ключ создан",
|
||||
"API key revoked": "API ключ отозван",
|
||||
"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?": "Вы уверены, что хотите удалить эту страницу?",
|
||||
"Are you sure you want to remove this user from the group? The user will lose access to resources this group has access to.": "Вы уверены, что хотите удалить этого пользователя из группы? Пользователь потеряет доступ к материалам, к которым есть доступ у этой группы.",
|
||||
"Are you sure you want to remove this user from the space? The user will lose all access to this space.": "Вы уверены, что хотите удалить этого пользователя из пространства? Пользователь потеряет весь доступ к этому пространству.",
|
||||
"Are you sure you want to restore this version? Any changes not versioned will be lost.": "Вы уверены, что хотите восстановить эту версию? Все не зафиксированные изменения будут потеряны.",
|
||||
"Are you sure you want to revoke \"{{name}}\"? Any client using this key will immediately lose access. This cannot be undone.": "Вы уверены, что хотите отозвать «{{name}}»? Любой клиент, использующий этот ключ, немедленно потеряет доступ. Это действие нельзя отменить.",
|
||||
"Author": "Автор",
|
||||
"Can become members of groups and spaces in workspace": "Могут становиться участниками групп и пространств в рабочей области",
|
||||
"Can create and edit pages in space.": "Может создавать и редактировать страницы в пространстве.",
|
||||
"Can edit": "Может изменять",
|
||||
@@ -41,14 +33,11 @@
|
||||
"Confirm": "Подтвердить",
|
||||
"Copy as Markdown": "Копировать как Markdown",
|
||||
"Copy link": "Копировать ссылку",
|
||||
"Copy your API key now and store it somewhere safe. For security reasons it will not be shown again.": "Скопируйте API ключ сейчас и сохраните его в надёжном месте. В целях безопасности он больше не будет показан.",
|
||||
"Create": "Создать",
|
||||
"Create API key": "Создать API ключ",
|
||||
"Create group": "Создать группу",
|
||||
"Create page": "Создать страницу",
|
||||
"Create space": "Создать пространство",
|
||||
"Create workspace": "Создать рабочую область",
|
||||
"Critical": "Критично",
|
||||
"Current password": "Текущий пароль",
|
||||
"Dark": "Темная",
|
||||
"Date": "Дата",
|
||||
@@ -57,7 +46,6 @@
|
||||
"Are you sure you want to delete this page? This will delete its children and page history. This action is irreversible.": "Вы уверены, что хотите удалить эту страницу? Это удалит её дочерние страницы, а также историю страницы. Это действие необратимо.",
|
||||
"Description": "Описание",
|
||||
"Details": "Подробности",
|
||||
"Done": "Готово",
|
||||
"e.g ACME": "например, ACME",
|
||||
"e.g ACME Inc": "например, ACME Inc",
|
||||
"e.g Developers": "например, Developers",
|
||||
@@ -67,15 +55,7 @@
|
||||
"e.g Sales": "например, Sales",
|
||||
"e.g Space for product team": "например, Пространство для команды продукта",
|
||||
"e.g Space for sales team to collaborate": "например, Пространство для совместной работы команды продаж",
|
||||
"e.g. CI deploy token": "например, токен деплоя CI",
|
||||
"Edit": "Редактировать",
|
||||
"Expiring soon": "Скоро истекает",
|
||||
"Failed to create API key": "Не удалось создать API ключ",
|
||||
"Failed to load API keys.": "Не удалось загрузить API ключи.",
|
||||
"Failed to revoke API key": "Не удалось отозвать API ключ",
|
||||
"Name is required": "Имя обязательно",
|
||||
"Never used": "Не использовался",
|
||||
"No API keys yet": "Пока нет API ключей",
|
||||
"Read": "Чтение",
|
||||
"Edit group": "Редактировать группу",
|
||||
"Email": "Электронная почта",
|
||||
@@ -128,7 +108,6 @@
|
||||
"Link copied": "Ссылка скопирована",
|
||||
"Login": "Войти",
|
||||
"Logout": "Выйти",
|
||||
"Major": "Существенно",
|
||||
"Manage Group": "Управление группой",
|
||||
"Manage members": "Управление участниками",
|
||||
"member": "участник",
|
||||
@@ -155,8 +134,6 @@
|
||||
"page": "страница",
|
||||
"Page deleted successfully": "Страница успешно удалена",
|
||||
"Page history": "История страницы",
|
||||
"Revoke API key": "Отозвать API ключ",
|
||||
"Revoke {{name}}": "Отозвать {{name}}",
|
||||
"Select version": "Выбрать версию",
|
||||
"Highlight changes": "Выделить изменения",
|
||||
"Page import is in progress. Please do not close this tab.": "Импорт страницы в процессе. Пожалуйста, не закрывайте эту вкладку.",
|
||||
@@ -212,9 +189,6 @@
|
||||
"Template": "Шаблон",
|
||||
"Templates": "Шаблоны",
|
||||
"Theme": "Тема",
|
||||
"This key expires within 30 days": "Этот ключ истекает в течение 30 дней",
|
||||
"This key has expired": "Этот ключ истек",
|
||||
"This key never expires": "Этот ключ никогда не истекает",
|
||||
"To change your email, you have to enter your password and new email.": "Чтобы изменить электронную почту, вам нужно ввести пароль и новый адрес.",
|
||||
"Toggle full page width": "Переключить полную ширину страницы",
|
||||
"Unable to import pages. Please try again.": "Не удалось импортировать страницы. Пожалуйста, попробуйте ещё раз.",
|
||||
@@ -222,7 +196,6 @@
|
||||
"Untitled": "Без названия",
|
||||
"Updated successfully": "Успешно обновлено",
|
||||
"User": "Пользователь",
|
||||
"Within the last hour": "За последний час",
|
||||
"Workspace": "Рабочее пространство",
|
||||
"Workspace Name": "Название рабочего пространства",
|
||||
"Workspace settings": "Настройки рабочего пространства",
|
||||
@@ -459,12 +432,9 @@
|
||||
"Write anything. Enter \"/\" for commands": "Пишите что угодно. Введите \"/\" для команд",
|
||||
"Write...": "Напишите...",
|
||||
"Column count": "Количество столбцов",
|
||||
"Your personal API keys.": "Ваши личные API ключи.",
|
||||
"{{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}} результат",
|
||||
@@ -1485,7 +1455,6 @@
|
||||
"agent: {{value}}": "агент: {{value}}",
|
||||
"Work": "Работа",
|
||||
"Agent": "Агент",
|
||||
"{{start}} – {{end}} · {{duration}}": "{{start}} – {{end}} · {{duration}}",
|
||||
"≈ {{hours}}h {{minutes}}m": "≈ {{hours}} ч {{minutes}} мин",
|
||||
"≈ {{hours}}h": "≈ {{hours}} ч",
|
||||
"≈ {{minutes}}m": "≈ {{minutes}} мин",
|
||||
|
||||
@@ -44,12 +44,6 @@ const AccountSettings = lazy(
|
||||
const AccountPreferences = lazy(
|
||||
() => import("@/pages/settings/account/account-preferences.tsx"),
|
||||
);
|
||||
// #506 — lazy leaf (own chunk): the API-keys management page is route-split so
|
||||
// its code (Mantine table/modals + the create/revoke flow) stays out of the
|
||||
// entry bundle (post-#342 bundle discipline).
|
||||
const AccountApiKeys = lazy(
|
||||
() => import("@/pages/settings/account/account-api-keys.tsx"),
|
||||
);
|
||||
const WorkspaceSettings = lazy(
|
||||
() => import("@/pages/settings/workspace/workspace-settings"),
|
||||
);
|
||||
@@ -111,7 +105,6 @@ export default function App() {
|
||||
path={"account/preferences"}
|
||||
element={<AccountPreferences />}
|
||||
/>
|
||||
<Route path={"account/api-keys"} element={<AccountApiKeys />} />
|
||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||
<Route path={"ai"} element={<AiSettings />} />
|
||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
IconBrush,
|
||||
IconWorld,
|
||||
IconSparkles,
|
||||
IconKey,
|
||||
} from "@tabler/icons-react";
|
||||
import { Link, useLocation } from "react-router-dom";
|
||||
import classes from "./settings.module.css";
|
||||
@@ -47,11 +46,6 @@ const groupedData: DataGroup[] = [
|
||||
icon: IconBrush,
|
||||
path: "/settings/account/preferences",
|
||||
},
|
||||
{
|
||||
label: "API keys",
|
||||
icon: IconKey,
|
||||
path: "/settings/account/api-keys",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,234 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { ModalsProvider } from "@mantine/modals";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import { UserRole } from "@/lib/types";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import { IApiKey } from "@/features/api-key/types/api-key.types";
|
||||
|
||||
// Mock the service layer so no real HTTP is attempted; every test drives the
|
||||
// component through these three functions.
|
||||
vi.mock("@/features/api-key/services/api-key-service", () => ({
|
||||
getApiKeys: vi.fn(),
|
||||
createApiKey: vi.fn(),
|
||||
revokeApiKey: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
revokeApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
import ApiKeysManager from "./api-keys-manager";
|
||||
|
||||
// matchMedia (read by MantineProvider) is stubbed globally in vitest.setup.ts.
|
||||
|
||||
const ISO_SOON = new Date(Date.now() + 10 * 864e5).toISOString();
|
||||
const ISO_FAR = new Date(Date.now() + 200 * 864e5).toISOString();
|
||||
const ISO_EXPIRED = new Date(Date.now() - 3 * 864e5).toISOString();
|
||||
|
||||
// Dump the storage stub via the Web Storage API — its data lives in a closure
|
||||
// (see vitest.setup.ts), so JSON.stringify(localStorage) would be vacuous.
|
||||
function storageDump(): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const k = localStorage.key(i) as string;
|
||||
out += `${k}=${localStorage.getItem(k)};`;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function makeKey(overrides: Partial<IApiKey> = {}): IApiKey {
|
||||
return {
|
||||
id: "key-1",
|
||||
name: "CI token",
|
||||
expiresAt: ISO_FAR,
|
||||
lastUsedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderManager(role: UserRole) {
|
||||
const store = createStore();
|
||||
store.set(currentUserAtom, {
|
||||
user: { id: "me", role } as never,
|
||||
workspace: {} as never,
|
||||
});
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
const utils = render(
|
||||
<Provider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<MantineProvider>
|
||||
<ModalsProvider>
|
||||
<ApiKeysManager />
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</QueryClientProvider>
|
||||
</Provider>,
|
||||
);
|
||||
return { store, queryClient, ...utils };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — list rendering", () => {
|
||||
it("renders an explicit expiry date and highlights a <30-day key (acceptance #3)", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ id: "k-soon", name: "Soon key", expiresAt: ISO_SOON }),
|
||||
makeKey({ id: "k-far", name: "Far key", expiresAt: ISO_FAR }),
|
||||
]);
|
||||
|
||||
renderManager(UserRole.MEMBER);
|
||||
|
||||
await screen.findByText("Soon key");
|
||||
// Explicit dates, not "in N days": the year is rendered verbatim.
|
||||
const soonYear = new Date(ISO_SOON).getFullYear().toString();
|
||||
expect(screen.getAllByText(new RegExp(soonYear)).length).toBeGreaterThan(0);
|
||||
// Exactly one key is inside the 30-day warning window.
|
||||
expect(screen.getAllByText("Expiring soon")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('shows "Expired" (not "Expiring soon") for an already-expired key', async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ id: "k-dead", name: "Dead key", expiresAt: ISO_EXPIRED }),
|
||||
]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("Dead key");
|
||||
// A past expiry is labelled "Expired", never the forward-looking badge.
|
||||
expect(screen.getByText("Expired")).toBeDefined();
|
||||
expect(screen.queryByText("Expiring soon")).toBeNull();
|
||||
});
|
||||
|
||||
it('shows "Never" for an unlimited key and no highlight', async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ id: "k-forever", name: "Forever", expiresAt: null }),
|
||||
]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("Forever");
|
||||
expect(screen.getByText("Never")).toBeDefined();
|
||||
expect(screen.queryByText("Expiring soon")).toBeNull();
|
||||
});
|
||||
|
||||
it("empty list shows the empty state", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
expect(await screen.findByText("No API keys yet")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — admin vs member view (acceptance #6)", () => {
|
||||
it("a member does NOT see the author column", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ creator: { id: "me", name: "Me", email: "m@x.io", avatarUrl: null } }),
|
||||
]);
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("CI token");
|
||||
// Author header absent + creator name not rendered (no author column).
|
||||
expect(screen.queryByText("Author")).toBeNull();
|
||||
expect(screen.queryByText("Me")).toBeNull();
|
||||
});
|
||||
|
||||
it("an admin sees the author column with the creator's name", async () => {
|
||||
vi.mocked(getApiKeys).mockResolvedValue([
|
||||
makeKey({ creator: { id: "u1", name: "Alice", email: "a@x.io", avatarUrl: null } }),
|
||||
]);
|
||||
renderManager(UserRole.ADMIN);
|
||||
await screen.findByText("CI token");
|
||||
expect(screen.getByText("Author")).toBeDefined();
|
||||
expect(screen.getByText("Alice")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ApiKeysManager — revoke (acceptance #4)", () => {
|
||||
it("revoke removes the row from the list", async () => {
|
||||
vi.mocked(getApiKeys)
|
||||
.mockResolvedValueOnce([
|
||||
makeKey({ id: "k1", name: "Doomed" }),
|
||||
makeKey({ id: "k2", name: "Survivor" }),
|
||||
])
|
||||
// After revoke, invalidation refetches the reduced list.
|
||||
.mockResolvedValue([makeKey({ id: "k2", name: "Survivor" })]);
|
||||
vi.mocked(revokeApiKey).mockResolvedValue();
|
||||
|
||||
renderManager(UserRole.MEMBER);
|
||||
await screen.findByText("Doomed");
|
||||
|
||||
fireEvent.click(screen.getByLabelText("Revoke Doomed"));
|
||||
// Confirm modal → click the destructive confirm button.
|
||||
const confirm = await screen.findByRole("button", { name: "Revoke" });
|
||||
fireEvent.click(confirm);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Doomed")).toBeNull(),
|
||||
);
|
||||
expect(screen.getByText("Survivor")).toBeDefined();
|
||||
expect(revokeApiKey).toHaveBeenCalledWith("k1");
|
||||
});
|
||||
});
|
||||
|
||||
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,
|
||||
apiKey: {
|
||||
id: "new-1",
|
||||
name: "My key",
|
||||
expiresAt: ISO_FAR,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
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],
|
||||
);
|
||||
const nameInput = await screen.findByLabelText(/Name/);
|
||||
fireEvent.change(nameInput, { target: { value: "My key" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create" }));
|
||||
|
||||
// Token is shown exactly once in the show-once modal.
|
||||
const tokenEl = await screen.findByTestId("api-key-token");
|
||||
expect(tokenEl.textContent).toBe(SECRET);
|
||||
|
||||
// Close the modal → token discarded from the DOM.
|
||||
fireEvent.click(screen.getByRole("button", { name: "Done" }));
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByTestId("api-key-token")).toBeNull(),
|
||||
);
|
||||
|
||||
// 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),
|
||||
);
|
||||
expect(cacheDump).not.toContain(SECRET);
|
||||
const mutationDump = JSON.stringify(
|
||||
queryClient.getMutationCache().getAll().map((m) => m.state.data),
|
||||
);
|
||||
expect(mutationDump).not.toContain(SECRET);
|
||||
});
|
||||
});
|
||||
@@ -1,264 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
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 {
|
||||
useApiKeysQuery,
|
||||
useCreateApiKeyMutation,
|
||||
useRevokeApiKeyMutation,
|
||||
} from "@/features/api-key/queries/api-key-query";
|
||||
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 { ShowTokenModal } from "./show-token-modal";
|
||||
|
||||
export default function ApiKeysManager() {
|
||||
const { t } = useTranslation();
|
||||
const locale = useDateFnsLocale();
|
||||
// Manage-on-API === Owner/Admin server-side, so the admin (workspace-wide)
|
||||
// list + "author" column mirror exactly the roles the server serves the
|
||||
// whole-workspace response to.
|
||||
const { isAdmin } = useUserRole();
|
||||
|
||||
const { data: keys, isLoading, isError } = useApiKeysQuery();
|
||||
const createMutation = useCreateApiKeyMutation();
|
||||
const revokeMutation = useRevokeApiKeyMutation();
|
||||
|
||||
const [createOpened, setCreateOpened] = useState(false);
|
||||
// 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 {
|
||||
const res = await createMutation.mutateAsync(values);
|
||||
setCreateOpened(false);
|
||||
// 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({
|
||||
message: t("Failed to create 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) =>
|
||||
modals.openConfirmModal({
|
||||
title: t("Revoke API key"),
|
||||
centered: true,
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t(
|
||||
'Are you sure you want to revoke "{{name}}"? Any client using this key will immediately lose access. This cannot be undone.',
|
||||
{ name: key.name },
|
||||
)}
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: t("Revoke"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => revokeMutation.mutate(key.id),
|
||||
});
|
||||
|
||||
const formatDate = (iso: string) =>
|
||||
formatLocalized(new Date(iso), "MMM dd, yyyy", "PP", locale);
|
||||
|
||||
const renderLastUsed = (lastUsedAt: string | null) => {
|
||||
switch (lastUsedBucket(lastUsedAt)) {
|
||||
case "never":
|
||||
return t("Never used");
|
||||
case "recent":
|
||||
return t("Within the last hour");
|
||||
case "stale":
|
||||
// Coarse relative time — last_used_at is throttled to ~1h server-side,
|
||||
// so we don't promise finer precision.
|
||||
return timeAgo(new Date(lastUsedAt as string));
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const rows = (keys ?? []).map((key) => {
|
||||
// Mutually exclusive: an already-expired key is labelled "Expired" (a past
|
||||
// expiry) rather than the forward-looking "Expiring soon". isExpiringSoon
|
||||
// also matches past expiries, so gate "soon" on !expired.
|
||||
const expired = isExpired(key.expiresAt);
|
||||
const soon = !expired && isExpiringSoon(key.expiresAt);
|
||||
return (
|
||||
<Table.Tr key={key.id}>
|
||||
<Table.Td>
|
||||
<Text fw={500}>{key.name}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{formatDate(key.createdAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
{key.expiresAt ? (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
<Text size="sm">{formatDate(key.expiresAt)}</Text>
|
||||
{expired && (
|
||||
<Tooltip label={t("This key has expired")} withArrow>
|
||||
<Badge
|
||||
color="red"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
>
|
||||
{t("Expired")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{soon && (
|
||||
<Tooltip
|
||||
label={t("This key expires within 30 days")}
|
||||
withArrow
|
||||
>
|
||||
<Badge
|
||||
color="orange"
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
>
|
||||
{t("Expiring soon")}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Never")}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{renderLastUsed(key.lastUsedAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{isAdmin && (
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{key.creator?.name ?? key.creator?.email ?? t("Unknown")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
)}
|
||||
<Table.Td style={{ textAlign: "right" }}>
|
||||
<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>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" mb="md">
|
||||
<Text size="sm" c="dimmed">
|
||||
{isAdmin
|
||||
? t("API keys across the workspace.")
|
||||
: t("Your personal API keys.")}
|
||||
</Text>
|
||||
<Button
|
||||
leftSection={<IconKey size={16} />}
|
||||
onClick={() => setCreateOpened(true)}
|
||||
>
|
||||
{t("Create API key")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{isError ? (
|
||||
<Text c="red" size="sm">
|
||||
{t("Failed to load API keys.")}
|
||||
</Text>
|
||||
) : rows.length === 0 ? (
|
||||
<Stack align="center" gap="xs" py="xl">
|
||||
<IconKey size={32} opacity={0.4} />
|
||||
<Text c="dimmed">{t("No API keys yet")}</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconKey size={16} />}
|
||||
onClick={() => setCreateOpened(true)}
|
||||
>
|
||||
{t("Create API key")}
|
||||
</Button>
|
||||
</Stack>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={600}>
|
||||
<Table verticalSpacing="sm" highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Name")}</Table.Th>
|
||||
<Table.Th>{t("Created")}</Table.Th>
|
||||
<Table.Th>{t("Expires")}</Table.Th>
|
||||
<Table.Th>{t("Last used")}</Table.Th>
|
||||
{isAdmin && <Table.Th>{t("Author")}</Table.Th>}
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>{rows}</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
|
||||
<CreateApiKeyModal
|
||||
opened={createOpened}
|
||||
onClose={() => setCreateOpened(false)}
|
||||
onSubmit={handleCreate}
|
||||
loading={createMutation.isPending}
|
||||
/>
|
||||
|
||||
<ShowTokenModal created={createdKey} onClose={handleCloseToken} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { Button, Group, Modal, Select, Stack, TextInput } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiKeyLifetime,
|
||||
DEFAULT_LIFETIME,
|
||||
lifetimeToExpiresAt,
|
||||
} from "@/features/api-key/utils";
|
||||
|
||||
interface Props {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
// Resolves the create request, returning true on success. The parent owns the
|
||||
// mutation (and the token it returns); this modal only collects the name +
|
||||
// lifetime. On failure (false) the form state is kept so the user can retry.
|
||||
onSubmit: (values: {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}) => Promise<boolean>;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
interface FormValues {
|
||||
name: string;
|
||||
lifetime: ApiKeyLifetime;
|
||||
}
|
||||
|
||||
export function CreateApiKeyModal({
|
||||
opened,
|
||||
onClose,
|
||||
onSubmit,
|
||||
loading,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
lifetime: DEFAULT_LIFETIME,
|
||||
},
|
||||
validate: {
|
||||
name: (value) =>
|
||||
value.trim().length === 0 ? t("Name is required") : null,
|
||||
},
|
||||
});
|
||||
|
||||
const lifetimeOptions: { value: ApiKeyLifetime; label: string }[] = [
|
||||
{ value: "30d", label: t("30 days") },
|
||||
{ value: "90d", label: t("90 days") },
|
||||
{ value: "1y", label: t("1 year") },
|
||||
{ value: "never", label: t("No expiration") },
|
||||
];
|
||||
|
||||
const handleSubmit = form.onSubmit(async (values) => {
|
||||
const ok = await onSubmit({
|
||||
name: values.name.trim(),
|
||||
expiresAt: lifetimeToExpiresAt(values.lifetime),
|
||||
});
|
||||
// Reset only after a successful submit so a failed create keeps the form
|
||||
// state (the parent surfaces the error via a notification).
|
||||
if (ok) form.reset();
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
form.reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={handleClose}
|
||||
title={t("Create API key")}
|
||||
centered
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t("Name")}
|
||||
placeholder={t("e.g. CI deploy token")}
|
||||
data-autofocus
|
||||
withAsterisk
|
||||
{...form.getInputProps("name")}
|
||||
/>
|
||||
<Select
|
||||
label={t("Expiration")}
|
||||
data={lifetimeOptions}
|
||||
allowDeselect={false}
|
||||
checkIconPosition="right"
|
||||
{...form.getInputProps("lifetime")}
|
||||
/>
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<Button variant="default" onClick={handleClose} type="button">
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button type="submit" loading={loading}>
|
||||
{t("Create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
createApiKey,
|
||||
getApiKeys,
|
||||
revokeApiKey,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
|
||||
export const API_KEYS_QUERY_KEY = ["api-keys"];
|
||||
|
||||
export function useApiKeysQuery(): UseQueryResult<IApiKey[], Error> {
|
||||
return useQuery({
|
||||
queryKey: API_KEYS_QUERY_KEY,
|
||||
queryFn: () => getApiKeys(),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create mutation.
|
||||
*
|
||||
* SECURITY: the response contains the token exactly once. This hook deliberately
|
||||
* does NOT stash it anywhere — the caller reads it from `mutateAsync`'s resolved
|
||||
* value, moves it into the show-once modal's local state, then calls
|
||||
* `mutation.reset()` to purge react-query's own copy immediately. `gcTime: 0`
|
||||
* is a second belt so nothing lingers in the mutation cache after the observer
|
||||
* unmounts. The list is invalidated here (the list carries no token).
|
||||
*/
|
||||
export function useCreateApiKeyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<ICreateApiKeyResponse, Error, ICreateApiKey>({
|
||||
mutationFn: (data) => createApiKey(data),
|
||||
gcTime: 0,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeApiKeyMutation() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation<void, Error, string>({
|
||||
mutationFn: (id) => revokeApiKey(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: API_KEYS_QUERY_KEY });
|
||||
notifications.show({ message: t("API key revoked") });
|
||||
},
|
||||
onError: () => {
|
||||
notifications.show({
|
||||
message: t("Failed to revoke API key"),
|
||||
color: "red",
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// Mock the api-client (axios instance). vi.mock replaces the whole module, so
|
||||
// the real response interceptor — which returns `response.data`, i.e. the
|
||||
// server envelope { data, success, status } — is bypassed. Our mocked post/get
|
||||
// therefore resolve to that post-interceptor envelope shape directly, and the
|
||||
// service under test reads `.data` off it to unwrap the inner payload. This is
|
||||
// the one bug-prone line the component tests (which fully mock the service)
|
||||
// never exercise.
|
||||
const { post, get } = vi.hoisted(() => ({ post: vi.fn(), get: vi.fn() }));
|
||||
vi.mock("@/lib/api-client", () => ({
|
||||
default: { post, get },
|
||||
}));
|
||||
|
||||
import {
|
||||
createApiKey,
|
||||
getApiKeys,
|
||||
} from "@/features/api-key/services/api-key-service";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("api-key-service response-contract unwrap", () => {
|
||||
it("createApiKey unwraps the envelope to { token, apiKey }", async () => {
|
||||
const payload = {
|
||||
token: "gm_secret-abc",
|
||||
apiKey: {
|
||||
id: "key-1",
|
||||
name: "CI token",
|
||||
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||
createdAt: "2026-07-11T12:00:00.000Z",
|
||||
},
|
||||
};
|
||||
// The server envelope as the interceptor hands it to the service.
|
||||
post.mockResolvedValue({ data: payload, success: true, status: 200 });
|
||||
|
||||
const result = await createApiKey({
|
||||
name: "CI token",
|
||||
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||
});
|
||||
|
||||
// The inner payload only — not the { data, success, status } wrapper.
|
||||
expect(result).toEqual(payload);
|
||||
expect(result.token).toBe("gm_secret-abc");
|
||||
expect(result.apiKey).toEqual(payload.apiKey);
|
||||
expect(post).toHaveBeenCalledWith("/api-keys/create", {
|
||||
name: "CI token",
|
||||
expiresAt: "2027-07-11T12:00:00.000Z",
|
||||
});
|
||||
});
|
||||
|
||||
it("getApiKeys unwraps the envelope to the array of rows", async () => {
|
||||
const rows = [
|
||||
{
|
||||
id: "key-1",
|
||||
name: "CI token",
|
||||
expiresAt: null,
|
||||
lastUsedAt: null,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "key-2",
|
||||
name: "Deploy token",
|
||||
expiresAt: "2027-01-01T00:00:00.000Z",
|
||||
lastUsedAt: null,
|
||||
createdAt: "2026-02-01T00:00:00.000Z",
|
||||
},
|
||||
];
|
||||
post.mockResolvedValue({ data: rows, success: true, status: 200 });
|
||||
|
||||
const result = await getApiKeys();
|
||||
|
||||
expect(result).toEqual(rows);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(post).toHaveBeenCalledWith("/api-keys/list");
|
||||
});
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
import api from "@/lib/api-client";
|
||||
import {
|
||||
IApiKey,
|
||||
ICreateApiKey,
|
||||
ICreateApiKeyResponse,
|
||||
} from "@/features/api-key/types/api-key.types";
|
||||
|
||||
// Mint a new key. The response carries the token ONCE — the caller must move it
|
||||
// straight into the show-once modal's local state and never cache it. See
|
||||
// queries/api-key-query.ts (gcTime: 0 + query invalidation) and
|
||||
// components/api-keys-manager.tsx `handleCreate` (createMutation.reset() right
|
||||
// after reading the token) for the reset()-after-read pattern.
|
||||
export async function createApiKey(
|
||||
data: ICreateApiKey,
|
||||
): Promise<ICreateApiKeyResponse> {
|
||||
const res = await api.post<ICreateApiKeyResponse>("/api-keys/create", data);
|
||||
return res.data as ICreateApiKeyResponse;
|
||||
}
|
||||
|
||||
// List the caller's keys (or, for an admin, every key in the workspace with
|
||||
// creator attribution). Never returns token material.
|
||||
export async function getApiKeys(): Promise<IApiKey[]> {
|
||||
const res = await api.post<IApiKey[]>("/api-keys/list");
|
||||
return res.data as IApiKey[];
|
||||
}
|
||||
|
||||
// Revocation is server-side immediate; the caller drops the row on success.
|
||||
export async function revokeApiKey(id: string): Promise<void> {
|
||||
await api.post("/api-keys/revoke", { id });
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
// Compact creator attribution embedded in the admin (workspace-wide) list. A
|
||||
// normal member's list only ever contains their own keys, so the field is
|
||||
// present but redundant; the author column is only rendered for admins.
|
||||
export interface IApiKeyCreator {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
avatarUrl: string | null;
|
||||
}
|
||||
|
||||
// A single api-key row as returned by `POST /api/api-keys/list`. Note: the list
|
||||
// NEVER carries token material — only metadata.
|
||||
export interface IApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
// ISO string, or null for an unlimited ("never expires") key.
|
||||
expiresAt: string | null;
|
||||
// ISO string, or null if the key was never used. Throttled to ~1h server-side
|
||||
// (#501), so the UI must not promise sub-hour precision.
|
||||
lastUsedAt: string | null;
|
||||
createdAt: string;
|
||||
creator?: IApiKeyCreator | null;
|
||||
}
|
||||
|
||||
// Payload for `POST /api/api-keys/create`. `expiresAt`: an ISO date string for a
|
||||
// bounded lifetime, or null for an unlimited key. (undefined would let the
|
||||
// server apply its 1-year default, but the form always sends an explicit value.)
|
||||
export interface ICreateApiKey {
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
}
|
||||
|
||||
// The metadata half of the create response. The token itself is carried
|
||||
// separately (see ICreateApiKeyResponse) and is shown exactly once.
|
||||
export interface ICreatedApiKey {
|
||||
id: string;
|
||||
name: string;
|
||||
expiresAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
DEFAULT_LIFETIME,
|
||||
EXPIRY_WARNING_DAYS,
|
||||
isExpired,
|
||||
isExpiringSoon,
|
||||
lastUsedBucket,
|
||||
lifetimeToExpiresAt,
|
||||
} from "./utils";
|
||||
|
||||
const NOW = new Date("2026-07-11T12:00:00.000Z");
|
||||
const daysFromNow = (n: number) =>
|
||||
new Date(NOW.getTime() + n * 24 * 60 * 60 * 1000).toISOString();
|
||||
|
||||
describe("lifetimeToExpiresAt", () => {
|
||||
it("default lifetime is 1 year (acceptance #7)", () => {
|
||||
expect(DEFAULT_LIFETIME).toBe("1y");
|
||||
const iso = lifetimeToExpiresAt("1y", NOW);
|
||||
expect(iso).toBe("2027-07-11T12:00:00.000Z");
|
||||
});
|
||||
|
||||
it('"never" sends null (acceptance #7)', () => {
|
||||
expect(lifetimeToExpiresAt("never", NOW)).toBeNull();
|
||||
});
|
||||
|
||||
it("30d / 90d map to the exact future instant", () => {
|
||||
expect(lifetimeToExpiresAt("30d", NOW)).toBe(daysFromNow(30));
|
||||
expect(lifetimeToExpiresAt("90d", NOW)).toBe(daysFromNow(90));
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExpiringSoon (acceptance #3 highlight)", () => {
|
||||
it("an unlimited key is never 'soon'", () => {
|
||||
expect(isExpiringSoon(null, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("highlights a key expiring within the 30-day window", () => {
|
||||
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(true);
|
||||
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not highlight a key well outside the window", () => {
|
||||
expect(isExpiringSoon(daysFromNow(EXPIRY_WARNING_DAYS + 1), NOW)).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isExpiringSoon(daysFromNow(200), NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("an already-expired key is highlighted", () => {
|
||||
expect(isExpiringSoon(daysFromNow(-3), NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExpired (past vs future expiry)", () => {
|
||||
it("an unlimited key is never expired", () => {
|
||||
expect(isExpired(null, NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("a past expiry is expired", () => {
|
||||
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
|
||||
expect(isExpired(daysFromNow(-1), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("a future expiry is not expired (even within the warning window)", () => {
|
||||
expect(isExpired(daysFromNow(1), NOW)).toBe(false);
|
||||
expect(isExpired(daysFromNow(EXPIRY_WARNING_DAYS - 1), NOW)).toBe(false);
|
||||
expect(isExpired(daysFromNow(200), NOW)).toBe(false);
|
||||
});
|
||||
|
||||
it("an expiry exactly at 'now' counts as expired (boundary is inclusive)", () => {
|
||||
expect(isExpired(NOW.toISOString(), NOW)).toBe(true);
|
||||
});
|
||||
|
||||
it("is mutually distinguishable from isExpiringSoon: expired vs soon-but-future", () => {
|
||||
// A key 3 days in the past: expired, and (by design) also matches
|
||||
// isExpiringSoon — the UI resolves this by checking isExpired first.
|
||||
expect(isExpired(daysFromNow(-3), NOW)).toBe(true);
|
||||
// A key 10 days in the future: NOT expired, but expiring soon.
|
||||
expect(isExpired(daysFromNow(10), NOW)).toBe(false);
|
||||
expect(isExpiringSoon(daysFromNow(10), NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lastUsedBucket (within-the-last-hour semantics)", () => {
|
||||
const minutesAgo = (n: number) =>
|
||||
new Date(NOW.getTime() - n * 60 * 1000).toISOString();
|
||||
|
||||
it("null last-used is 'never'", () => {
|
||||
expect(lastUsedBucket(null, NOW)).toBe("never");
|
||||
});
|
||||
|
||||
it("under an hour is 'recent' (no sub-hour precision promised)", () => {
|
||||
expect(lastUsedBucket(minutesAgo(5), NOW)).toBe("recent");
|
||||
expect(lastUsedBucket(minutesAgo(59), NOW)).toBe("recent");
|
||||
});
|
||||
|
||||
it("over an hour is 'stale'", () => {
|
||||
expect(lastUsedBucket(minutesAgo(90), NOW)).toBe("stale");
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
import { addDays, addYears, differenceInMinutes } from "date-fns";
|
||||
|
||||
// The single early-warning window (#501/#506): keys whose expiry is closer than
|
||||
// this are visually highlighted in the list. Also used to classify a key as
|
||||
// already-expired (a negative "days until" is < 30 too).
|
||||
export const EXPIRY_WARNING_DAYS = 30;
|
||||
|
||||
// last_used_at is throttled to ~1h server-side, so anything under an hour is
|
||||
// shown as the coarse "within the last hour" rather than a false-precise
|
||||
// "5 minutes ago".
|
||||
export const LAST_USED_THROTTLE_MINUTES = 60;
|
||||
|
||||
export type ApiKeyLifetime = "30d" | "90d" | "1y" | "never";
|
||||
|
||||
export const DEFAULT_LIFETIME: ApiKeyLifetime = "1y";
|
||||
|
||||
// Maps a lifetime choice to the `expiresAt` payload sent to the server: an ISO
|
||||
// string for a bounded lifetime, or null for an explicit unlimited key.
|
||||
export function lifetimeToExpiresAt(
|
||||
lifetime: ApiKeyLifetime,
|
||||
now: Date = new Date(),
|
||||
): string | null {
|
||||
switch (lifetime) {
|
||||
case "30d":
|
||||
return addDays(now, 30).toISOString();
|
||||
case "90d":
|
||||
return addDays(now, 90).toISOString();
|
||||
case "1y":
|
||||
return addYears(now, 1).toISOString();
|
||||
case "never":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// True when a bounded key's expiry is already in the past (or exactly now). An
|
||||
// unlimited key (null) is never expired. This is distinct from "expiring soon":
|
||||
// the two states are mutually exclusive at the call site (see api-keys-manager),
|
||||
// so an already-expired key is labelled "Expired", not "Expiring soon".
|
||||
export function isExpired(
|
||||
expiresAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const expiry = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiry)) return false;
|
||||
return expiry <= now.getTime();
|
||||
}
|
||||
|
||||
// True when a bounded key expires within the warning window (or is already
|
||||
// expired). An unlimited key (null) is never "expiring soon". Callers that need
|
||||
// to distinguish an already-past expiry should check isExpired() first, as this
|
||||
// predicate deliberately also covers the already-expired case.
|
||||
export function isExpiringSoon(
|
||||
expiresAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): boolean {
|
||||
if (!expiresAt) return false;
|
||||
const expiry = new Date(expiresAt).getTime();
|
||||
if (Number.isNaN(expiry)) return false;
|
||||
const msLeft = expiry - now.getTime();
|
||||
return msLeft < EXPIRY_WARNING_DAYS * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
// Classifies last-used recency so the caller can pick the honest label without
|
||||
// promising sub-hour precision. Returns "never", "recent" (< 1h) or "stale".
|
||||
export function lastUsedBucket(
|
||||
lastUsedAt: string | null,
|
||||
now: Date = new Date(),
|
||||
): "never" | "recent" | "stale" {
|
||||
if (!lastUsedAt) return "never";
|
||||
const used = new Date(lastUsedAt);
|
||||
if (Number.isNaN(used.getTime())) return "never";
|
||||
return differenceInMinutes(now, used) < LAST_USED_THROTTLE_MINUTES
|
||||
? "recent"
|
||||
: "stale";
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,418 +0,0 @@
|
||||
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,6 +156,125 @@ 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 } from "@mantine/core";
|
||||
import { Group, Text, Box, Badge, Button } from "@mantine/core";
|
||||
import { AgentAvatarStack } from "@/components/ui/agent-avatar-stack.tsx";
|
||||
import React, { useRef, useState } from "react";
|
||||
import React, { useMemo, useRef, useState } from "react";
|
||||
import classes from "./comment.module.css";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
@@ -12,11 +12,18 @@ 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";
|
||||
@@ -25,21 +32,13 @@ interface CommentListItemProps {
|
||||
comment: IComment;
|
||||
pageId: string;
|
||||
canComment: boolean;
|
||||
// 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).
|
||||
// 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).
|
||||
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,
|
||||
@@ -56,20 +55,29 @@ 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);
|
||||
|
||||
// `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;
|
||||
// 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],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
const isOwnerOrAdmin =
|
||||
currentUser?.user?.id === comment.creatorId || userSpaceRole === "admin";
|
||||
|
||||
const isAgent = comment.createdSource === "agent" && !!comment.agent;
|
||||
|
||||
async function handleUpdateComment() {
|
||||
try {
|
||||
@@ -113,9 +121,34 @@ function CommentListItem({
|
||||
}
|
||||
}
|
||||
|
||||
function handleCommentClick(target: IComment) {
|
||||
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) {
|
||||
const el = document.querySelector(
|
||||
`.comment-mark[data-comment-id="${target.id}"]`,
|
||||
`.comment-mark[data-comment-id="${comment.id}"]`,
|
||||
);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
@@ -136,10 +169,10 @@ function CommentListItem({
|
||||
|
||||
return (
|
||||
<Box ref={ref} pb={6}>
|
||||
<Group gap="xs" wrap="nowrap" align="flex-start">
|
||||
{isAgent ? (
|
||||
<Group gap="xs">
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<AgentAvatarStack
|
||||
agent={comment.agent!}
|
||||
agent={comment.agent}
|
||||
launcher={comment.launcher}
|
||||
aiChatId={comment.aiChatId}
|
||||
showName={false}
|
||||
@@ -152,13 +185,13 @@ function CommentListItem({
|
||||
/>
|
||||
)}
|
||||
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap={6} wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
{isAgent ? (
|
||||
{comment.createdSource === "agent" && comment.agent ? (
|
||||
<>
|
||||
<Text size="xs" fw={600} lineClamp={1} lh={1.2}>
|
||||
{comment.agent!.name}
|
||||
{comment.agent.name}
|
||||
</Text>
|
||||
{comment.launcher && (
|
||||
<>
|
||||
@@ -229,6 +262,87 @@ 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} />
|
||||
) : (
|
||||
@@ -236,9 +350,7 @@ 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,
|
||||
Box,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Badge,
|
||||
@@ -14,9 +14,6 @@ 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,
|
||||
@@ -25,10 +22,6 @@ 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";
|
||||
@@ -76,32 +69,6 @@ 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();
|
||||
@@ -123,8 +90,6 @@ 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) {
|
||||
@@ -148,17 +113,6 @@ 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.
|
||||
@@ -214,104 +168,56 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
[createCommentAsync, page?.id],
|
||||
);
|
||||
|
||||
// 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
|
||||
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
|
||||
comment={comment}
|
||||
pageId={page?.id}
|
||||
canComment={canComment}
|
||||
canEdit={canEdit}
|
||||
userSpaceRole={userSpaceRole}
|
||||
showProvenance={showProvenance}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
) : (
|
||||
<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={userSpaceRole}
|
||||
userSpaceRole={space?.membership?.role}
|
||||
/>
|
||||
</Box>
|
||||
</div>
|
||||
|
||||
{!comment.resolvedAt && canComment && (
|
||||
<Box px="xs" pb="xs">
|
||||
<>
|
||||
<Divider my={2} />
|
||||
<CommentEditorWithActions
|
||||
commentId={comment.id}
|
||||
onSave={handleAddReply}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Paper>
|
||||
),
|
||||
[
|
||||
childrenByParent,
|
||||
handleAddReply,
|
||||
page?.id,
|
||||
userSpaceRole,
|
||||
space?.membership?.role,
|
||||
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 <></>;
|
||||
}
|
||||
@@ -320,6 +226,8 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
return <div>{t("Error loading comments.")}</div>;
|
||||
}
|
||||
|
||||
const totalComments = activeComments.length + resolvedComments.length;
|
||||
|
||||
const pageCommentInput = canComment ? (
|
||||
<PageCommentInput
|
||||
onSave={handleAddPageComment}
|
||||
@@ -420,7 +328,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
activeUnits.map(renderUnit)
|
||||
activeComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -439,7 +347,7 @@ function CommentListWithTabs({ onClose }: CommentListWithTabsProps) {
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
resolvedUnits.map(renderUnit)
|
||||
resolvedComments.map(renderComments)
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,53 @@
|
||||
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) {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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"]);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,76 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
// 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,23 +92,13 @@
|
||||
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 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). */
|
||||
the FIRST DOM child (#146). */
|
||||
.definitionContent {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.definitionContent p:first-child::before {
|
||||
.definitionContent > :first-child::before {
|
||||
content: var(--footnote-number, "?") ". ";
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-variant-numeric: tabular-nums;
|
||||
@@ -118,13 +108,12 @@
|
||||
/* 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. Target the paragraphs directly (through the
|
||||
tiptap-react content wrapper), same reasoning as the ::before rule above. */
|
||||
.definitionContent p:first-child {
|
||||
callouts in core.css. */
|
||||
.definitionContent > :first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.definitionContent p:last-child {
|
||||
.definitionContent > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
buildRows,
|
||||
formatBlockTooltip,
|
||||
summaryLabels,
|
||||
toBlocks,
|
||||
toTimelineDay,
|
||||
EMPTY_RUN_COLLAPSE,
|
||||
} from "./work-time-adapter";
|
||||
import { IPageWorkTime, IPerDay } from "./work-time.types";
|
||||
|
||||
const MIN = 60 * 1000;
|
||||
const HOUR = 60 * MIN;
|
||||
const DAY_MS = 24 * HOUR;
|
||||
|
||||
// Fake translator: renders the key with {{tokens}} substituted, so the tests
|
||||
// assert the mapping/branching without depending on the i18n catalogue.
|
||||
const t = (key: string, opts?: Record<string, unknown>) =>
|
||||
key.replace(/\{\{(\w+)\}\}/g, (_, k) => String(opts?.[k] ?? ""));
|
||||
|
||||
// A day midnight anchored at a fixed UTC instant (tz handled server-side; we
|
||||
// only lay out fractions of the day the server already bucketed).
|
||||
const DAY0 = Date.UTC(2026, 5, 29, 0, 0, 0); // Mon 29 Jun 2026 00:00 UTC
|
||||
|
||||
function day(over: Partial<IPerDay> & { day: number }): IPerDay {
|
||||
return {
|
||||
dayISO: new Date(over.day).toISOString().slice(0, 10),
|
||||
activeMs: 0,
|
||||
agentMs: 0,
|
||||
windows: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const config: IPageWorkTime["config"] = {
|
||||
tGap: 15 * MIN,
|
||||
agentTGap: 15 * MIN,
|
||||
pIn: 0,
|
||||
pOut: 0,
|
||||
pSingle: 30 * 1000,
|
||||
excludeGit: false,
|
||||
dedupRoundMs: 1000,
|
||||
};
|
||||
|
||||
function payload(over: Partial<IPageWorkTime>): IPageWorkTime {
|
||||
return {
|
||||
workMs: 0,
|
||||
agentOnlyMs: 0,
|
||||
perDay: [],
|
||||
config,
|
||||
tz: "UTC",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toBlocks", () => {
|
||||
it("maps work+agent windows to time-of-day segments (hour fractions)", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
activeMs: 90 * MIN,
|
||||
windows: [
|
||||
{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" },
|
||||
{ start: DAY0 + 22 * HOUR, end: DAY0 + 23 * HOUR, class: "agent_only" },
|
||||
],
|
||||
});
|
||||
const blocks = toBlocks(d);
|
||||
expect(blocks).toHaveLength(2);
|
||||
expect(blocks[0]).toMatchObject({ start: 9, end: 10.5, kind: "work" });
|
||||
expect(blocks[1]).toMatchObject({ start: 22, end: 23, kind: "agent" });
|
||||
// real epoch preserved for the DST-safe tooltip
|
||||
expect(blocks[0].startEpoch).toBe(DAY0 + 9 * HOUR);
|
||||
});
|
||||
|
||||
it("clamps out-of-day fractions to [0,24]", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
windows: [{ start: DAY0 - HOUR, end: DAY0 + 25 * HOUR, class: "work" }],
|
||||
});
|
||||
const [b] = toBlocks(d);
|
||||
expect(b.start).toBe(0);
|
||||
expect(b.end).toBe(24);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toTimelineDay", () => {
|
||||
it("draws the now-line only on today's row", () => {
|
||||
const today = day({ day: DAY0, activeMs: HOUR });
|
||||
const noon = DAY0 + 12 * HOUR;
|
||||
const asToday = toTimelineDay(today, t, noon);
|
||||
expect(asToday.isToday).toBe(true);
|
||||
expect(asToday.nowFraction).toBeCloseTo(0.5, 5);
|
||||
|
||||
const past = day({ day: DAY0 - DAY_MS, activeMs: HOUR });
|
||||
const asPast = toTimelineDay(past, t, noon);
|
||||
expect(asPast.isToday).toBe(false);
|
||||
expect(asPast.nowFraction).toBeUndefined();
|
||||
});
|
||||
|
||||
it("labels an empty day and totals it as —", () => {
|
||||
const empty = day({ day: DAY0 });
|
||||
const d = toTimelineDay(empty, t, DAY0 + 12 * HOUR);
|
||||
expect(d.isEmpty).toBe(true);
|
||||
expect(d.totalLabel).toBe("—");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRows (empty-run collapsing)", () => {
|
||||
it("collapses a run of >= EMPTY_RUN_COLLAPSE empty days in place", () => {
|
||||
const perDay: IPerDay[] = [
|
||||
day({ day: DAY0, activeMs: HOUR }),
|
||||
...Array.from({ length: EMPTY_RUN_COLLAPSE }, (_, i) =>
|
||||
day({ day: DAY0 + (i + 1) * DAY_MS }),
|
||||
),
|
||||
day({ day: DAY0 + (EMPTY_RUN_COLLAPSE + 1) * DAY_MS, activeMs: HOUR }),
|
||||
];
|
||||
const rows = buildRows(perDay, t, 0);
|
||||
expect(rows.map((r) => r.type)).toEqual(["day", "gap", "day"]);
|
||||
const gap = rows[1];
|
||||
expect(gap.type === "gap" && gap.count).toBe(EMPTY_RUN_COLLAPSE);
|
||||
});
|
||||
|
||||
it("keeps a short empty run as individual dimmed day rows", () => {
|
||||
const perDay: IPerDay[] = [
|
||||
day({ day: DAY0, activeMs: HOUR }),
|
||||
day({ day: DAY0 + DAY_MS }),
|
||||
day({ day: DAY0 + 2 * DAY_MS, activeMs: HOUR }),
|
||||
];
|
||||
const rows = buildRows(perDay, t, 0);
|
||||
expect(rows.map((r) => r.type)).toEqual(["day", "day", "day"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summaryLabels", () => {
|
||||
it("derives totalLabel/agentLabel from ms via the shared formatter", () => {
|
||||
const { total, agent } = summaryLabels(
|
||||
payload({ workMs: 4 * HOUR + 27 * MIN, agentOnlyMs: 80 * MIN }),
|
||||
t,
|
||||
);
|
||||
expect(total).toBe("≈ 4h 25m");
|
||||
expect(agent).toBe("≈ 1h 20m");
|
||||
});
|
||||
|
||||
it("agent-only page: agent estimate fills the main slot, no secondary line", () => {
|
||||
const { total, agent } = summaryLabels(
|
||||
payload({ workMs: 0, agentOnlyMs: 80 * MIN }),
|
||||
t,
|
||||
);
|
||||
expect(total).toBe("agent: ≈ 1h 20m");
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("human-only page: no agent line", () => {
|
||||
const { agent } = summaryLabels(payload({ workMs: HOUR, agentOnlyMs: 0 }), t);
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatBlockTooltip", () => {
|
||||
it("formats start–end from the real epoch in the data tz + duration", () => {
|
||||
const d = day({
|
||||
day: DAY0,
|
||||
windows: [{ start: DAY0 + 9 * HOUR, end: DAY0 + 10 * HOUR + 30 * MIN, class: "work" }],
|
||||
});
|
||||
const [b] = toBlocks(d);
|
||||
const label = formatBlockTooltip(b, "UTC", "en-US", t);
|
||||
expect(label).toBe("09:00 – 10:30 · 1h 30m");
|
||||
});
|
||||
});
|
||||
@@ -1,168 +0,0 @@
|
||||
// #566 — adapter: real IPageWorkTime → the render-ready shape the redesigned
|
||||
// time-of-day timeline consumes. The visual prototype (NewDesign/TimeWorkedModal)
|
||||
// was written against invented props (`DaySummary[]`, pre-made `totalLabel`); the
|
||||
// real payload is IPageWorkTime. Everything below is pure so the mapping (windows
|
||||
// → time-of-day blocks, ms → labels, the "now" boundary, empty-run collapsing) is
|
||||
// unit-testable without React. No re-bucketing: the server already grouped the
|
||||
// windows by the request timezone — we only lay out the windows it returned.
|
||||
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import { formatDayTotal, formatHeadline } from "./format-work-time";
|
||||
|
||||
type Translate = (key: string, opts?: Record<string, unknown>) => string;
|
||||
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — preserved from the original punch-card.
|
||||
export const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
export interface TimelineBlock {
|
||||
/** Hour fraction 0..24 within the day — drives left/width positioning. */
|
||||
start: number;
|
||||
end: number;
|
||||
kind: "work" | "agent";
|
||||
/** Real epoch ms, kept for a DST-safe tooltip (formatted in the data tz). */
|
||||
startEpoch: number;
|
||||
endEpoch: number;
|
||||
}
|
||||
|
||||
export interface TimelineDay {
|
||||
key: string;
|
||||
label: string;
|
||||
totalLabel: string;
|
||||
blocks: TimelineBlock[];
|
||||
isEmpty: boolean;
|
||||
/** Today lives only in the last active bucket; drives the "now" boundary. */
|
||||
isToday: boolean;
|
||||
/** 0..1 position of "now" within today's track (undefined when not today). */
|
||||
nowFraction?: number;
|
||||
}
|
||||
|
||||
export type TimelineRow =
|
||||
| { type: "day"; day: TimelineDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
/** Weekday-day-month heading in the browser locale (matches the server tz
|
||||
* bucketing, since usePageWorkTime requests buckets in the viewer tz). */
|
||||
export function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
/** Map one day's absolute windows into time-of-day blocks. `class` work|agent_only
|
||||
* → kind work|agent; positions are the in-day hour fraction (epoch kept for the
|
||||
* tooltip). Fractions are clamped to [0,24] to match the original punch-card. */
|
||||
export function toBlocks(day: IPerDay): TimelineBlock[] {
|
||||
return day.windows.map((w: IDayWindow) => {
|
||||
const start = clampHour((w.start - day.day) / (DAY_MS / 24));
|
||||
const end = clampHour((w.end - day.day) / (DAY_MS / 24));
|
||||
return {
|
||||
start,
|
||||
end,
|
||||
kind: w.class === "work" ? "work" : "agent",
|
||||
startEpoch: w.start,
|
||||
endEpoch: w.end,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function clampHour(h: number): number {
|
||||
return Math.max(0, Math.min(24, h));
|
||||
}
|
||||
|
||||
/** Build a single render-ready day. `now` is injected for testability; the
|
||||
* "now" boundary is drawn only when `now` falls inside this bucket's calendar
|
||||
* day (so it appears on today's row and only when today has edits). */
|
||||
export function toTimelineDay(
|
||||
day: IPerDay,
|
||||
t: Translate,
|
||||
now: number,
|
||||
): TimelineDay {
|
||||
const isToday = now >= day.day && now < day.day + DAY_MS;
|
||||
return {
|
||||
key: day.dayISO,
|
||||
label: dayHeading(day.day),
|
||||
totalLabel: formatDayTotal(day.activeMs, t),
|
||||
blocks: toBlocks(day),
|
||||
isEmpty: day.activeMs === 0 && day.agentMs === 0,
|
||||
isToday,
|
||||
nowFraction: isToday ? (now - day.day) / DAY_MS : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Collapse long edit-free runs (≥ EMPTY_RUN_COLLAPSE) into an in-place "gap"
|
||||
* row; short runs stay as (dimmed, "—") day rows. Preserved from the original
|
||||
* punch-card so a page edited over months does not render hundreds of rows. */
|
||||
export function buildRows(
|
||||
perDay: IPerDay[],
|
||||
t: Translate,
|
||||
now: number,
|
||||
): TimelineRow[] {
|
||||
const rows: TimelineRow[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) {
|
||||
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||
}
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: toTimelineDay(d, t, now) });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** The big summary slot. Fail-safe for an agent-only page (#395/#551): since
|
||||
* formatHeadline(0) === "", never leave the 22px slot empty — put the agent
|
||||
* estimate in the main slot, and only show the secondary `agent:` line when
|
||||
* BOTH a human and an agent estimate exist. */
|
||||
export function summaryLabels(
|
||||
data: IPageWorkTime,
|
||||
t: Translate,
|
||||
): { total: string; agent?: string } {
|
||||
const total =
|
||||
data.workMs > 0
|
||||
? formatHeadline(data.workMs, t)
|
||||
: t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) });
|
||||
const agent =
|
||||
data.workMs > 0 && data.agentOnlyMs > 0
|
||||
? formatHeadline(data.agentOnlyMs, t)
|
||||
: undefined;
|
||||
return { total, agent };
|
||||
}
|
||||
|
||||
/** Block hover label "start – end · duration". Times come from the REAL epoch
|
||||
* rendered in the data tz (NOT the 24h fraction) so a DST-transition day does
|
||||
* not skew the shown clock time. Duration reuses formatDayTotal (always > 0
|
||||
* here, so never "—"). */
|
||||
export function formatBlockTooltip(
|
||||
block: TimelineBlock,
|
||||
tz: string,
|
||||
locale: string,
|
||||
t: Translate,
|
||||
): string {
|
||||
const fmt = new Intl.DateTimeFormat(locale || undefined, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
timeZone: tz,
|
||||
});
|
||||
return t("{{start}} – {{end}} · {{duration}}", {
|
||||
start: fmt.format(block.startEpoch),
|
||||
end: fmt.format(block.endEpoch),
|
||||
duration: formatDayTotal(block.endEpoch - block.startEpoch, t),
|
||||
});
|
||||
}
|
||||
@@ -1,86 +1,97 @@
|
||||
// #566 — redesigned "Time worked" body: daily time-of-day timelines. Each day is
|
||||
// a 24h track showing WHEN the work happened — a sticky 00/06/12/18/24 hour axis,
|
||||
// shaded night hours, per-block hover tooltip ("start – end · duration"), and a
|
||||
// "now" boundary on today's row. Presentation ported from NewDesign/TimeWorkedModal;
|
||||
// all data comes through the pure adapter over the real IPageWorkTime (zero backend
|
||||
// change). Positioning math, empty-run collapsing and the formatters are reused.
|
||||
import { Box, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
|
||||
import { Group, Stack, Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMemo } from "react";
|
||||
import { IPageWorkTime } from "./work-time.types";
|
||||
import { formatGapMinutes } from "./format-work-time";
|
||||
import { IPageWorkTime, IPerDay, IDayWindow } from "./work-time.types";
|
||||
import {
|
||||
buildRows,
|
||||
formatBlockTooltip,
|
||||
summaryLabels,
|
||||
TimelineBlock,
|
||||
TimelineDay,
|
||||
} from "./work-time-adapter";
|
||||
formatDayTotal,
|
||||
formatGapMinutes,
|
||||
formatHeadline,
|
||||
} from "./format-work-time";
|
||||
import classes from "./work-time.module.css";
|
||||
|
||||
// Minimum visible width so a very short session neither vanishes nor fakes dense
|
||||
// work (§6.2); kept from the original punch-card.
|
||||
const MIN_BLOCK_WIDTH_PCT = 0.6;
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
// Collapse a run of this many (or more) consecutive edit-free days into a single
|
||||
// "× N days" separator (§6.2 long-range) — the row is still always one day.
|
||||
const EMPTY_RUN_COLLAPSE = 8;
|
||||
|
||||
function ActivityBlock({
|
||||
block,
|
||||
tz,
|
||||
locale,
|
||||
}: {
|
||||
block: TimelineBlock;
|
||||
tz: string;
|
||||
locale: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const left = (block.start / 24) * 100;
|
||||
const width = Math.max(((block.end - block.start) / 24) * 100, MIN_BLOCK_WIDTH_PCT);
|
||||
const cls = [
|
||||
classes.window,
|
||||
block.kind === "work" ? classes.windowWork : classes.windowAgent,
|
||||
].join(" ");
|
||||
return (
|
||||
<Tooltip
|
||||
label={formatBlockTooltip(block, tz, locale, t)}
|
||||
withArrow
|
||||
openDelay={120}
|
||||
fz={11}
|
||||
>
|
||||
<div className={cls} style={{ left: `${left}%`, width: `${width}%` }} />
|
||||
</Tooltip>
|
||||
);
|
||||
type Row =
|
||||
| { type: "day"; day: IPerDay }
|
||||
| { type: "gap"; count: number };
|
||||
|
||||
function collapseEmptyRuns(perDay: IPerDay[]): Row[] {
|
||||
const rows: Row[] = [];
|
||||
let emptyRun: IPerDay[] = [];
|
||||
const flush = () => {
|
||||
if (emptyRun.length >= EMPTY_RUN_COLLAPSE) {
|
||||
rows.push({ type: "gap", count: emptyRun.length });
|
||||
} else {
|
||||
for (const d of emptyRun) rows.push({ type: "day", day: d });
|
||||
}
|
||||
emptyRun = [];
|
||||
};
|
||||
for (const d of perDay) {
|
||||
if (d.activeMs === 0 && d.agentMs === 0) {
|
||||
emptyRun.push(d);
|
||||
} else {
|
||||
flush();
|
||||
rows.push({ type: "day", day: d });
|
||||
}
|
||||
}
|
||||
flush();
|
||||
return rows;
|
||||
}
|
||||
|
||||
function dayHeading(day: number): string {
|
||||
return new Date(day).toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
});
|
||||
}
|
||||
|
||||
function DayTrack({
|
||||
day,
|
||||
tz,
|
||||
locale,
|
||||
pSingle,
|
||||
}: {
|
||||
day: TimelineDay;
|
||||
tz: string;
|
||||
locale: string;
|
||||
day: IPerDay;
|
||||
pSingle: number;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const ticks = [6, 12, 18];
|
||||
return (
|
||||
<div className={classes.row}>
|
||||
<span className={classes.dayLabel}>{day.label}</span>
|
||||
<div className={`${classes.track} ${day.isEmpty ? classes.trackEmpty : ""}`}>
|
||||
{[25, 50, 75].map((p) => (
|
||||
<div key={p} className={classes.hourTick} style={{ left: `${p}%` }} />
|
||||
))}
|
||||
{day.blocks.map((b, i) => (
|
||||
<ActivityBlock key={i} block={b} tz={tz} locale={locale} />
|
||||
))}
|
||||
{day.isToday && day.nowFraction != null && (
|
||||
<span className={classes.dayLabel}>{dayHeading(day.day)}</span>
|
||||
<div className={classes.track}>
|
||||
{ticks.map((h) => (
|
||||
<div
|
||||
className={classes.nowLine}
|
||||
style={{ left: `${day.nowFraction * 100}%` }}
|
||||
key={h}
|
||||
className={classes.hourTick}
|
||||
style={{ left: `${(h / 24) * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
{day.windows.map((w: IDayWindow, i) => {
|
||||
const leftPct = ((w.start - day.day) / DAY_MS) * 100;
|
||||
const widthPct = ((w.end - w.start) / DAY_MS) * 100;
|
||||
const isSingle = w.end - w.start <= pSingle;
|
||||
const cls = [
|
||||
classes.window,
|
||||
w.class === "work" ? classes.windowWork : classes.windowAgent,
|
||||
isSingle ? classes.windowSingle : "",
|
||||
].join(" ");
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={cls}
|
||||
style={{
|
||||
left: `${Math.max(0, Math.min(100, leftPct))}%`,
|
||||
width: `${Math.max(0, Math.min(100, widthPct))}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span
|
||||
className={classes.daySum}
|
||||
data-empty={day.totalLabel === "—" ? true : undefined}
|
||||
>
|
||||
{day.totalLabel}
|
||||
<span className={classes.daySum}>
|
||||
{formatDayTotal(day.activeMs, t)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@@ -91,16 +102,8 @@ interface Props {
|
||||
}
|
||||
|
||||
export default function WorkTimePunchCard({ data }: Props) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language;
|
||||
const now = Date.now();
|
||||
const rows = useMemo(
|
||||
() => buildRows(data.perDay, t, now),
|
||||
// `now` intentionally re-read on each open; excluded so the memo tracks data.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[data.perDay, t],
|
||||
);
|
||||
const { total, agent } = summaryLabels(data, t);
|
||||
const { t } = useTranslation();
|
||||
const rows = useMemo(() => collapseEmptyRuns(data.perDay), [data.perDay]);
|
||||
const gapMin = formatGapMinutes(data.config.tGap);
|
||||
|
||||
if (data.workMs <= 0 && data.agentOnlyMs <= 0) {
|
||||
@@ -113,19 +116,17 @@ export default function WorkTimePunchCard({ data }: Props) {
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
{/* summary */}
|
||||
<Group align="baseline" gap="md">
|
||||
<Text fz={22} fw={700}>
|
||||
{total}
|
||||
<Group gap="lg">
|
||||
<Text size="sm" fw={500}>
|
||||
{formatHeadline(data.workMs, t)}
|
||||
</Text>
|
||||
{agent && (
|
||||
{data.agentOnlyMs > 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("agent: {{value}}", { value: agent })}
|
||||
{t("agent: {{value}}", { value: formatHeadline(data.agentOnlyMs, t) })}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{/* legend */}
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
<span
|
||||
@@ -143,47 +144,21 @@ export default function WorkTimePunchCard({ data }: Props) {
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
{/* sticky hour axis */}
|
||||
<div className={`${classes.row} ${classes.axisRow}`}>
|
||||
<span />
|
||||
<div className={classes.axis}>
|
||||
{[
|
||||
["0%", "00", "start"],
|
||||
["25%", "06", "center"],
|
||||
["50%", "12", "center"],
|
||||
["75%", "18", "center"],
|
||||
["100%", "24", "end"],
|
||||
].map(([l, label, align]) => (
|
||||
<span
|
||||
key={label}
|
||||
className={classes.axisTick}
|
||||
data-align={align}
|
||||
style={{ left: l }}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<span />
|
||||
</div>
|
||||
|
||||
{/* day rows */}
|
||||
<ScrollArea.Autosize mah="60vh" type="hover">
|
||||
<div>
|
||||
{rows.map((row, i) =>
|
||||
row.type === "day" ? (
|
||||
<DayTrack
|
||||
key={row.day.key}
|
||||
key={row.day.dayISO}
|
||||
day={row.day}
|
||||
tz={data.tz}
|
||||
locale={locale}
|
||||
pSingle={data.config.pSingle}
|
||||
/>
|
||||
) : (
|
||||
<Box key={`gap-${i}`} className={classes.gapRow}>
|
||||
<div key={`gap-${i}`} className={classes.gapRow}>
|
||||
{t("× {{count}} days without edits", { count: row.count })}
|
||||
</Box>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</ScrollArea.Autosize>
|
||||
</div>
|
||||
|
||||
<Text size="xs" c="dimmed" mt="xs">
|
||||
{t("Estimate · timezone {{tz}} · inactivity gap {{gap}} min", {
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function WorkTimeStat({ pageId }: Props) {
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("Time worked on this article")}
|
||||
size="46rem"
|
||||
size="lg"
|
||||
>
|
||||
<WorkTimePunchCard data={data} />
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
/* #566 — time-of-day timeline. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library.
|
||||
Night hours (0–6, 21–24) are shaded so the eye reads morning-vs-evening; a
|
||||
sticky hour axis labels 00/06/12/18/24. Theme-aware via Mantine tokens. */
|
||||
/* #395 — 24h × days punch-card. Custom CSS segments on a fixed 24-hour track
|
||||
(position = offset-in-day / 24h, width = duration / 24h), no chart library. */
|
||||
|
||||
.row {
|
||||
display: grid;
|
||||
@@ -18,29 +16,17 @@
|
||||
}
|
||||
|
||||
.track {
|
||||
--wt-night: rgba(90, 100, 130, 0.16);
|
||||
--wt-day: rgba(90, 100, 130, 0.03);
|
||||
position: relative;
|
||||
height: 20px;
|
||||
border-radius: 5px;
|
||||
height: 16px;
|
||||
border-radius: 4px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-gray-1),
|
||||
var(--mantine-color-dark-6)
|
||||
);
|
||||
overflow: hidden;
|
||||
/* base fill + night shading: 0–6h and 21–24h (87.5%) darker than daytime */
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
var(--wt-night) 0 25%,
|
||||
var(--wt-day) 25% 87.5%,
|
||||
var(--wt-night) 87.5% 100%
|
||||
),
|
||||
light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));
|
||||
}
|
||||
|
||||
/* Short (< EMPTY_RUN_COLLAPSE) edit-free days: dimmed, empty track. */
|
||||
.trackEmpty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Quarter-day grid divisions (06/12/18) inside the track. */
|
||||
/* Faint hour grid so the eye can read "morning vs evening". */
|
||||
.hourTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
@@ -54,11 +40,10 @@
|
||||
|
||||
.window {
|
||||
position: absolute;
|
||||
top: 3px;
|
||||
height: 14px;
|
||||
top: 2px;
|
||||
bottom: 2px;
|
||||
border-radius: 3px;
|
||||
min-width: 3px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.windowWork {
|
||||
@@ -69,57 +54,18 @@
|
||||
background-color: var(--mantine-color-grape-5);
|
||||
}
|
||||
|
||||
/* The "now" boundary on today's row. */
|
||||
.nowLine {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: var(--mantine-color-red-6);
|
||||
/* A lone single-sample (P_single) window: minimal + dimmed, so it neither
|
||||
vanishes nor fakes dense work (§6.2). */
|
||||
.windowSingle {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.daySum {
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.daySum[data-empty] {
|
||||
color: var(--mantine-color-dimmed);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* Sticky hour axis header aligned to the track column. */
|
||||
.axisRow {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
background-color: var(--mantine-color-body);
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
|
||||
.axis {
|
||||
position: relative;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.axisTick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
}
|
||||
|
||||
.axisTick[data-align="center"] {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.axisTick[data-align="end"] {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
|
||||
.gapRow {
|
||||
padding: 6px 0 6px 108px;
|
||||
font-size: var(--mantine-font-size-xs);
|
||||
|
||||
@@ -665,13 +665,6 @@ export function updateCacheOnMovePage(
|
||||
pageData: Partial<IPage>,
|
||||
) {
|
||||
invalidatePageTree();
|
||||
// Invalidate the moved page's breadcrumbs (#523). The tree-side child-loss
|
||||
// guard removes the moved node from the local tree when its new parent is an
|
||||
// unloaded branch, so `findBreadcrumbPath` misses it and the breadcrumb bar
|
||||
// falls back to the server `["breadcrumbs", pageId]` query — which this move
|
||||
// must invalidate, otherwise the crumbs keep showing the OLD parent until a
|
||||
// refocus/navigation.
|
||||
queryClient.invalidateQueries({ queryKey: ["breadcrumbs", pageId] });
|
||||
// Remove page from old parent's cache
|
||||
const oldQueryKey =
|
||||
oldParentId === null
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import type { IPage } from "@/features/page/types/page.types";
|
||||
|
||||
// A fresh QueryClient stands in for the app singleton (importing the real
|
||||
// @/main.tsx would run ReactDOM.createRoot, which has no DOM root in jsdom).
|
||||
vi.mock("@/main.tsx", async () => {
|
||||
const { QueryClient } = await import("@tanstack/react-query");
|
||||
return { queryClient: new QueryClient() };
|
||||
});
|
||||
|
||||
import { queryClient } from "@/main.tsx";
|
||||
import { updateCacheOnMovePage } from "./page-query";
|
||||
|
||||
// #523: the tree-side child-loss guard removes the moved node from the local
|
||||
// tree when its new parent is an unloaded branch, so `findBreadcrumbPath` misses
|
||||
// it and the breadcrumb bar falls back to the server `["breadcrumbs", pageId]`
|
||||
// query. That query MUST be invalidated by a move, or the crumbs keep showing
|
||||
// the OLD parent until a refocus/navigation.
|
||||
describe("updateCacheOnMovePage — breadcrumbs invalidation (#523)", () => {
|
||||
beforeEach(() => {
|
||||
queryClient.clear();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("invalidates the moved page's ['breadcrumbs', pageId] query", () => {
|
||||
const spy = vi.spyOn(queryClient, "invalidateQueries");
|
||||
|
||||
updateCacheOnMovePage("s1", "moved-page", "old-parent", "new-parent", {
|
||||
id: "moved-page",
|
||||
} as Partial<IPage>);
|
||||
|
||||
const invalidatedBreadcrumbs = spy.mock.calls.some(
|
||||
([arg]) =>
|
||||
Array.isArray((arg as { queryKey?: unknown[] })?.queryKey) &&
|
||||
(arg as { queryKey: unknown[] }).queryKey[0] === "breadcrumbs" &&
|
||||
(arg as { queryKey: unknown[] }).queryKey[1] === "moved-page",
|
||||
);
|
||||
expect(invalidatedBreadcrumbs).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,14 +55,7 @@ type Props<T extends object> = {
|
||||
};
|
||||
|
||||
const DRAG_TYPE = 'doc-tree-item';
|
||||
// Hover-hold before a collapsed row auto-expands during a drag. 2s (not ~0.5s)
|
||||
// so merely dragging the cursor THROUGH the tree never expands rows — only a
|
||||
// deliberate hold does (#523).
|
||||
const AUTO_EXPAND_MS = 2000;
|
||||
// How long the "a page just moved in here" cue stays on a collapsed target after
|
||||
// a make-child drop. Long enough to notice at a glance during frequent
|
||||
// collapsed-drops, short enough not to linger. Code-only UX constant (#523).
|
||||
const DROP_LANDED_HIGHLIGHT_MS = 1800;
|
||||
const AUTO_EXPAND_MS = 500;
|
||||
|
||||
function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
const {
|
||||
@@ -100,11 +93,7 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
const rowRef = useRef<HTMLElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [instruction, setInstruction] = useState<Instruction | null>(null);
|
||||
// Transient "just received a child" cue: a make-child drop no longer expands
|
||||
// the (collapsed) target, so flash the row instead so the move isn't invisible.
|
||||
const [landedChild, setLandedChild] = useState(false);
|
||||
const autoExpandTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const landedChildTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const cancelAutoExpand = useCallback(() => {
|
||||
if (autoExpandTimerRef.current) {
|
||||
@@ -260,24 +249,11 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
? getDragLabel(sourceNode)
|
||||
: 'item';
|
||||
liveRegion.announce(`Moved ${sourceLabel} under ${parentName}.`);
|
||||
// Do NOT auto-expand the target on drop: a drop must leave the node
|
||||
// collapsed. Intentional expansion is handled solely by the
|
||||
// hover-hold timer (AUTO_EXPAND_MS). Feedback that the drop landed is
|
||||
// given by the post-move flash + landed-cue highlight + live-region
|
||||
// announce above. When the make-child target is collapsed, flash a
|
||||
// distinct "child moved in here" cue on the row (it stays collapsed).
|
||||
if (op.kind === 'make-child' && !isOpen) {
|
||||
if (landedChildTimerRef.current) {
|
||||
clearTimeout(landedChildTimerRef.current);
|
||||
}
|
||||
setLandedChild(true);
|
||||
landedChildTimerRef.current = setTimeout(() => {
|
||||
setLandedChild(false);
|
||||
landedChildTimerRef.current = null;
|
||||
}, DROP_LANDED_HIGHLIGHT_MS);
|
||||
}
|
||||
// Restore the openness of the MOVED page itself (source) — untouched
|
||||
// by the above; the target is never expanded here.
|
||||
// After a make-child drop, expand this row so the user sees the
|
||||
// just-dropped child — especially important when the row had no
|
||||
// children before (chevron just appeared) so the drop would
|
||||
// otherwise be invisible.
|
||||
if (op.kind === 'make-child') onToggle(node.id, true);
|
||||
if (source.data.isOpenOnDragStart) onToggle(sourceId, true);
|
||||
},
|
||||
}),
|
||||
@@ -305,17 +281,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
|
||||
useEffect(() => () => cancelAutoExpand(), [cancelAutoExpand]);
|
||||
|
||||
// Clear the landed-child cue timer on unmount (mirrors autoExpandTimerRef).
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (landedChildTimerRef.current) {
|
||||
clearTimeout(landedChildTimerRef.current);
|
||||
landedChildTimerRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const effectiveInst =
|
||||
instruction?.type === 'instruction-blocked'
|
||||
? instruction.desired
|
||||
@@ -352,7 +317,6 @@ function DocTreeRowInner<T extends object>(props: Props<T>) {
|
||||
className={styles.node}
|
||||
data-dragging={isDragging || undefined}
|
||||
data-selected={isSelected || undefined}
|
||||
data-landed-child={landedChild || undefined}
|
||||
data-receiving-drop={
|
||||
receivingDrop === 'make-child'
|
||||
? blocked
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, act } from "@testing-library/react";
|
||||
import { Provider, createStore } from "jotai";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||
import { treeModel } from "@/features/page/tree/model/tree-model";
|
||||
import type { SpaceTreeNode } from "@/features/page/tree/types";
|
||||
|
||||
// --- Boundary mocks: only the network/query + router/i18n surfaces the hook
|
||||
// touches. The tree math (treeModel, dropOpToMovePayload) runs for real so the
|
||||
// child-loss guard is exercised end-to-end.
|
||||
const moveMutate = vi.fn().mockResolvedValue({});
|
||||
const updateCacheOnMovePageMock = vi.fn();
|
||||
vi.mock("@/features/page/queries/page-query.ts", () => ({
|
||||
useCreatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useUpdatePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useRemovePageMutation: () => ({ mutateAsync: vi.fn() }),
|
||||
useMovePageMutation: () => ({ mutateAsync: moveMutate }),
|
||||
updateCacheOnMovePage: (...args: unknown[]) =>
|
||||
updateCacheOnMovePageMock(...args),
|
||||
}));
|
||||
vi.mock("react-router-dom", () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useParams: () => ({ spaceSlug: "space", pageSlug: undefined }),
|
||||
}));
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({ t: (s: string) => s }),
|
||||
}));
|
||||
vi.mock("@mantine/notifications", () => ({
|
||||
notifications: { show: vi.fn() },
|
||||
}));
|
||||
|
||||
// Import AFTER mocks so the hook binds to them.
|
||||
import { useTreeMutation } from "./use-tree-mutation";
|
||||
|
||||
function node(
|
||||
id: string,
|
||||
over: Partial<SpaceTreeNode> = {},
|
||||
): SpaceTreeNode {
|
||||
return {
|
||||
id,
|
||||
slugId: `slug-${id}`,
|
||||
name: id.toUpperCase(),
|
||||
position: "a0",
|
||||
spaceId: "space-1",
|
||||
parentPageId: null as unknown as string,
|
||||
hasChildren: false,
|
||||
children: [],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function setup(before: SpaceTreeNode[]) {
|
||||
const store = createStore();
|
||||
store.set(treeDataAtom, before);
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<Provider store={store}>{children}</Provider>
|
||||
);
|
||||
const { result } = renderHook(() => useTreeMutation("space-1"), { wrapper });
|
||||
return { store, result };
|
||||
}
|
||||
|
||||
describe("useTreeMutation.handleMove — child-loss guard (#523)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
moveMutate.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it("make-child into an UNLOADED folder does NOT materialize [source] — keeps it lazy-loadable", async () => {
|
||||
// F has children on the server but none are loaded here (canonical unloaded
|
||||
// form: hasChildren + children:[]). X sits at root.
|
||||
const before = [
|
||||
node("F", { position: "a0", hasChildren: true, children: [] }),
|
||||
node("X", { position: "a5" }),
|
||||
];
|
||||
const { store, result } = setup(before);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("X", {
|
||||
kind: "make-child",
|
||||
targetId: "F",
|
||||
});
|
||||
});
|
||||
|
||||
const tree = store.get(treeDataAtom);
|
||||
const f = treeModel.find(tree, "F");
|
||||
// The guard leaves F unloaded (children stay []), so a later expand fetches
|
||||
// the FULL server set (incl. X) instead of showing a misleading partial [X].
|
||||
// MUTATION: dropping the guard (using the `move` result) would put children
|
||||
// === [X] here and redden this.
|
||||
expect(f?.children).toEqual([]);
|
||||
expect(f?.hasChildren).toBe(true);
|
||||
// X is removed from its old (root) slot; it reappears on expand/load of F.
|
||||
expect(treeModel.find(tree, "X")).toBeNull();
|
||||
// The server move is still persisted.
|
||||
expect(moveMutate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("make-child into a LOADED folder appends source and KEEPS the existing children", async () => {
|
||||
// F is loaded with one child c1; moving X in must preserve c1 (no loss) and
|
||||
// append X — this path does NOT hit the guard.
|
||||
const before = [
|
||||
node("F", {
|
||||
position: "a0",
|
||||
hasChildren: true,
|
||||
children: [node("c1", { position: "a1", parentPageId: "F" })],
|
||||
}),
|
||||
node("X", { position: "a5" }),
|
||||
];
|
||||
const { store, result } = setup(before);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("X", {
|
||||
kind: "make-child",
|
||||
targetId: "F",
|
||||
});
|
||||
});
|
||||
|
||||
const tree = store.get(treeDataAtom);
|
||||
const f = treeModel.find(tree, "F");
|
||||
expect(f?.children?.map((n) => n.id)).toEqual(["c1", "X"]);
|
||||
expect(f?.hasChildren).toBe(true);
|
||||
// X now lives under F.
|
||||
expect(treeModel.find(tree, "X")?.parentPageId).toBe("F");
|
||||
});
|
||||
|
||||
it("make-child into a genuinely-empty leaf materializes the child (nothing to lose)", async () => {
|
||||
// Leaf L has no server children (hasChildren:false). Dropping X in should
|
||||
// show X immediately — the guard must NOT fire here.
|
||||
const before = [
|
||||
node("L", { position: "a0", hasChildren: false, children: [] }),
|
||||
node("X", { position: "a5" }),
|
||||
];
|
||||
const { store, result } = setup(before);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("X", {
|
||||
kind: "make-child",
|
||||
targetId: "L",
|
||||
});
|
||||
});
|
||||
|
||||
const tree = store.get(treeDataAtom);
|
||||
const l = treeModel.find(tree, "L");
|
||||
expect(l?.children?.map((n) => n.id)).toEqual(["X"]);
|
||||
expect(l?.hasChildren).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -61,48 +61,11 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
if (!source) return;
|
||||
const oldParentId = source.parentPageId ?? null;
|
||||
|
||||
// Child-loss guard (#523, twin of #525's realtime `insertByPosition` fix).
|
||||
// We no longer auto-expand the make-child target on drop, so the old
|
||||
// `onToggle(target, true)` — which was ALSO the only trigger of the
|
||||
// corrective lazy-load — is gone. `treeModel.move` materialized
|
||||
// `target.children = [source]` (only the moved node); if the target is an
|
||||
// UNLOADED branch (server has children but none are loaded here), keeping
|
||||
// that partial `[source]` list would defeat the lazy-load gate and hide the
|
||||
// target's OTHER server children (the #159 #1 data-loss class). So for an
|
||||
// unloaded make-child target, build the optimistic tree WITHOUT
|
||||
// materializing source under it: just remove source from its old parent and
|
||||
// flag the target `hasChildren`. The gate stays armed and a later manual
|
||||
// expand fetches the FULL set (incl. the moved page, which the awaited
|
||||
// server move persists). Predicate is the gate's (`isUnloadedBranch`), NOT
|
||||
// `insertByPosition`'s old `=== undefined` (canonical unloaded is `[]`).
|
||||
const target =
|
||||
op.kind === "make-child"
|
||||
? (treeModel.find(before, op.targetId) as SpaceTreeNode | null)
|
||||
: null;
|
||||
const unloadedMakeChild =
|
||||
op.kind === "make-child" && treeModel.isUnloadedBranch(target);
|
||||
|
||||
let optimistic: SpaceTreeNode[];
|
||||
if (unloadedMakeChild) {
|
||||
// Do NOT materialize [source] into the unloaded target.
|
||||
optimistic = treeModel.remove(before, sourceId);
|
||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
} else {
|
||||
// optimistic apply with the new position from the payload
|
||||
optimistic = treeModel.update(after, sourceId, {
|
||||
position: payload.position,
|
||||
parentPageId: payload.parentPageId,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
// For make-child onto a previously-childless (loaded) target: flip
|
||||
// hasChildren on so the new parent shows its chevron.
|
||||
if (op.kind === "make-child") {
|
||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
}
|
||||
// optimistic apply with the new position from the payload
|
||||
let optimistic = treeModel.update(after, sourceId, {
|
||||
position: payload.position,
|
||||
parentPageId: payload.parentPageId,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
|
||||
// If the old parent has no children left, mark hasChildren: false so the
|
||||
// chevron disappears. Without this, the empty parent keeps rendering an
|
||||
@@ -116,6 +79,14 @@ export function useTreeMutation(spaceId: string): UseTreeMutation {
|
||||
}
|
||||
}
|
||||
|
||||
// For make-child onto a previously-childless target: flip hasChildren on
|
||||
// so the new parent shows its chevron.
|
||||
if (op.kind === "make-child") {
|
||||
optimistic = treeModel.update(optimistic, op.targetId, {
|
||||
hasChildren: true,
|
||||
} as Partial<SpaceTreeNode>);
|
||||
}
|
||||
|
||||
setData(optimistic);
|
||||
|
||||
try {
|
||||
|
||||
@@ -150,45 +150,6 @@
|
||||
);
|
||||
}
|
||||
|
||||
/* "A page just moved in here" cue (#523). A make-child drop no longer expands
|
||||
the collapsed target, so the moved page is momentarily invisible; pulse the
|
||||
target row in a distinct teal (NOT the blue make-child highlight, NOT the
|
||||
neutral post-move flash) so the landing is noticeable while the node stays
|
||||
collapsed. Two short pulses fit inside DROP_LANDED_HIGHLIGHT_MS (1.8s), after
|
||||
which the row clears the attribute and the animation stops. */
|
||||
@keyframes landedChildPulse {
|
||||
0% {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-teal-2),
|
||||
rgba(45, 212, 191, 0.30)
|
||||
);
|
||||
outline-color: light-dark(
|
||||
var(--mantine-color-teal-6),
|
||||
var(--mantine-color-teal-5)
|
||||
);
|
||||
}
|
||||
100% {
|
||||
background-color: transparent;
|
||||
outline-color: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.node[data-landed-child="true"] {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: -1px;
|
||||
animation: landedChildPulse 0.9s ease-out 2;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.node[data-landed-child="true"] {
|
||||
animation: none;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-teal-1),
|
||||
rgba(45, 212, 191, 0.18)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.dropLine {
|
||||
position: absolute;
|
||||
left: var(--drop-line-indent, 0);
|
||||
|
||||
@@ -29,6 +29,7 @@ import ShareAliasSection from "@/features/share/components/share-alias-section.t
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { usePageHistoryListQuery } from "@/features/page-history/queries/page-history-query.ts";
|
||||
|
||||
interface ShareModalProps {
|
||||
readOnly: boolean;
|
||||
@@ -54,6 +55,33 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
// if level is greater than zero, then it is a descendant page from a shared page
|
||||
const isDescendantShared = share && share.level > 0;
|
||||
|
||||
// #370 Stage B — "publish only the saved version". Mirrors the server XOR:
|
||||
// approved and includeSubPages are mutually exclusive.
|
||||
const isApproved = share?.publishedMode === "approved";
|
||||
const includeSubPages = share?.includeSubPages ?? false;
|
||||
|
||||
// Resolve the latest MANUAL version (the one an approved share publishes) so
|
||||
// the modal can caption it and warn when the live draft has drifted ahead.
|
||||
// Only fetched for a directly-shared page whose modal is actually open.
|
||||
const { data: historyData } = usePageHistoryListQuery(
|
||||
pageIsShared ? pageId : "",
|
||||
);
|
||||
const latestManual = useMemo(() => {
|
||||
const items = historyData?.pages?.flatMap((p) => p.items) ?? [];
|
||||
// The list is newest-first; the first manual row is the published version.
|
||||
return items.find((h) => h.kind === "manual") ?? null;
|
||||
}, [historyData]);
|
||||
|
||||
// The live draft has edits newer than the published saved version when the
|
||||
// page was updated after that manual snapshot was taken.
|
||||
const hasUnpublishedEdits = useMemo(() => {
|
||||
if (!isApproved || !latestManual || !page?.updatedAt) return false;
|
||||
return (
|
||||
new Date(page.updatedAt).getTime() >
|
||||
new Date(latestManual.createdAt).getTime()
|
||||
);
|
||||
}, [isApproved, latestManual, page?.updatedAt]);
|
||||
|
||||
const publicLink = `${getAppUrl()}/share/${share?.key}/p/${pageSlug}`;
|
||||
|
||||
const [isPagePublic, setIsPagePublic] = useState<boolean>(false);
|
||||
@@ -115,6 +143,23 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
}
|
||||
};
|
||||
|
||||
// #370 Stage B — toggle between publishing the live draft ('live') and the
|
||||
// last saved version ('approved'). The server rejects approved + sub-pages, so
|
||||
// the UI keeps the two toggles mutually exclusive (see the disabled props).
|
||||
const handlePublishedModeChange = async (
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
await updateShareMutation.mutateAsync({
|
||||
shareId: share.id,
|
||||
publishedMode: value ? "approved" : "live",
|
||||
});
|
||||
} catch {
|
||||
// query invalidation will revert the UI
|
||||
}
|
||||
};
|
||||
|
||||
const shareLink = useMemo(
|
||||
() => (
|
||||
<Group my="sm" gap={4} wrap="nowrap">
|
||||
@@ -238,11 +283,42 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
|
||||
|
||||
<Switch
|
||||
onChange={handleSubPagesChange}
|
||||
checked={share.includeSubPages}
|
||||
checked={includeSubPages}
|
||||
size="xs"
|
||||
disabled={readOnly}
|
||||
// XOR with approved mode: a version-frozen share cannot also
|
||||
// publish a whole sub-tree.
|
||||
disabled={readOnly || isApproved}
|
||||
/>
|
||||
</Group>
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{t("Publish only the saved version")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{isApproved
|
||||
? t(
|
||||
"Visitors see the last saved version, not live edits",
|
||||
)
|
||||
: t("Visitors see live edits as you make them")}
|
||||
</Text>
|
||||
</div>
|
||||
<Switch
|
||||
onChange={handlePublishedModeChange}
|
||||
checked={isApproved}
|
||||
size="xs"
|
||||
// XOR with sub-pages: hidden intent is enforced by disabling
|
||||
// this toggle whenever sub-pages are shared.
|
||||
disabled={readOnly || includeSubPages}
|
||||
/>
|
||||
</Group>
|
||||
{isApproved && hasUnpublishedEdits && (
|
||||
<Text size="xs" c="orange" mt={4}>
|
||||
{t(
|
||||
"You have unsaved changes newer than the published version",
|
||||
)}
|
||||
</Text>
|
||||
)}
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl" mt="sm">
|
||||
<div>
|
||||
<Text size="sm">{t("Search engine indexing")}</Text>
|
||||
|
||||
@@ -6,6 +6,9 @@ export interface IShare {
|
||||
pageId: string;
|
||||
includeSubPages: boolean;
|
||||
searchIndexing: boolean;
|
||||
// #370 Stage B — 'live' serves the current draft; 'approved' serves the last
|
||||
// manually-saved version. Mutually exclusive with includeSubPages.
|
||||
publishedMode: "live" | "approved";
|
||||
creatorId: string;
|
||||
spaceId: string;
|
||||
workspaceId: string;
|
||||
@@ -75,6 +78,7 @@ export interface ICreateShare {
|
||||
pageId?: string;
|
||||
includeSubPages?: boolean;
|
||||
searchIndexing?: boolean;
|
||||
publishedMode?: "live" | "approved";
|
||||
}
|
||||
|
||||
export type IUpdateShare = ICreateShare & { shareId: string; pageId?: string };
|
||||
|
||||
@@ -36,7 +36,6 @@ const STATIC_ROUTES = new Set<string>([
|
||||
'/setup/register',
|
||||
'/settings/account/profile',
|
||||
'/settings/account/preferences',
|
||||
'/settings/account/api-keys',
|
||||
'/settings/workspace',
|
||||
'/settings/ai',
|
||||
'/settings/members',
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import SettingsTitle from "@/components/settings/settings-title.tsx";
|
||||
import ApiKeysManager from "@/features/api-key/components/api-keys-manager";
|
||||
import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export default function AccountApiKeys() {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>
|
||||
{t("API keys")} - {getAppName()}
|
||||
</title>
|
||||
</Helmet>
|
||||
<SettingsTitle title={t("API keys")} />
|
||||
|
||||
<ApiKeysManager />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -14,11 +14,9 @@
|
||||
*/
|
||||
|
||||
/*
|
||||
* Anchor the top toast containers to the very top edge of the viewport (a small
|
||||
* 8px gap), above the header/search chrome, per product request. The toast
|
||||
* (z-index 10000) therefore renders over the header/toolbar (both z-index 99)
|
||||
* for the few seconds it is visible — intentional, since it is the top-most,
|
||||
* in-the-line-of-sight surface.
|
||||
* Push the top-anchored toast containers below the top chrome (fixed 45px
|
||||
* header + optional 45px format toolbar + ~6px gap) so a toast (z-index 10000)
|
||||
* neither covers nor intercepts clicks on the header/toolbar (both z-index 99).
|
||||
*
|
||||
* Scoped to [data-position^='top'] on purpose. Mantine renders ALL SIX position
|
||||
* containers simultaneously (`position` only routes toasts into one via the
|
||||
@@ -36,7 +34,7 @@
|
||||
* (the :where() contributes 0), regardless of stylesheet order.
|
||||
*/
|
||||
.mantine-Notifications-root[data-position^='top'] {
|
||||
top: 8px;
|
||||
top: 96px;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme='light'] .mantine-Notification-root {
|
||||
|
||||
@@ -63,15 +63,3 @@ vi.stubGlobal("matchMedia", (query: string) => ({
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mantine's ScrollArea (used by Table.ScrollContainer, ScrollArea, etc.) reads
|
||||
// `ResizeObserver` in a layout effect on mount, which jsdom does not implement.
|
||||
// A no-op stub lets any test rendering those components mount cleanly.
|
||||
vi.stubGlobal(
|
||||
"ResizeObserver",
|
||||
class {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -736,53 +736,6 @@ describe('PersistenceExtension.onStoreDocument — Approach-A boundary snapshot'
|
||||
expect(historyQueue.remove).toHaveBeenCalledWith(PAGE_ID);
|
||||
expect(historyQueue.remove).not.toHaveBeenCalledWith(SLUG);
|
||||
});
|
||||
|
||||
// #370 F2 — an effectively-empty page is a REACHABLE no-op (agent calls
|
||||
// save_page_version on a blank page): the version tx short-circuits with
|
||||
// nothing to pin. The handler MUST still broadcast a TERMINAL reply
|
||||
// (version.skipped, reason:'empty') so the client resolves at once instead of
|
||||
// waiting out its 20s ack timeout and misreporting a healthy server as
|
||||
// unreachable. MUTATION: drop the `else if (skipped)` broadcast → no terminal
|
||||
// reply is sent → this reddens.
|
||||
it('empty page → no version written, broadcasts a terminal version.skipped(empty)', async () => {
|
||||
const emptyDoc = { type: 'doc', content: [{ type: 'paragraph' }] };
|
||||
const document = ydocFor(emptyDoc);
|
||||
pageRepo.findById.mockResolvedValue({
|
||||
...persistedHumanPage('IGNORED'),
|
||||
content: emptyDoc,
|
||||
});
|
||||
|
||||
await emitSave(document, 'agent');
|
||||
|
||||
// Nothing pinned.
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect(pageHistoryRepo.updateHistoryKind).not.toHaveBeenCalled();
|
||||
// But a terminal reply WAS sent so the client never times out. The flush
|
||||
// (onStoreDocument) emits its own `page.updated`; the version.skipped is the
|
||||
// LAST broadcast (dropping the skip branch leaves page.updated last → reds).
|
||||
const calls = (document as any).broadcastStateless.mock.calls;
|
||||
const msg = JSON.parse(calls[calls.length - 1][0]);
|
||||
expect(msg).toEqual({ type: 'version.skipped', reason: 'empty' });
|
||||
});
|
||||
|
||||
// #370 F2 — the page row is gone (deleted / never persisted). Same rule: a
|
||||
// terminal reply MUST be sent (version.skipped, reason:'page-not-found') so the
|
||||
// client surfaces a truthful "not found" immediately rather than a health
|
||||
// timeout. onStoreDocument's own `!page` guard returns early without throwing,
|
||||
// so the handler reaches the version tx and its `!page` skip branch.
|
||||
it('page not found → broadcasts a terminal version.skipped(page-not-found)', async () => {
|
||||
const document = ydocFor(doc('GONE'));
|
||||
pageRepo.findById.mockResolvedValue(null);
|
||||
|
||||
await emitSave(document, 'agent');
|
||||
|
||||
expect(pageHistoryRepo.saveHistory).not.toHaveBeenCalled();
|
||||
expect((document as any).broadcastStateless).toHaveBeenCalledTimes(1);
|
||||
const msg = JSON.parse(
|
||||
(document as any).broadcastStateless.mock.calls[0][0],
|
||||
);
|
||||
expect(msg).toEqual({ type: 'version.skipped', reason: 'page-not-found' });
|
||||
});
|
||||
});
|
||||
|
||||
// #370 — the in-memory idle-burst marker must be dropped on doc unload (like
|
||||
|
||||
@@ -68,19 +68,6 @@ export const INTENTIONAL_CLEAR_MESSAGE_TYPE = 'intentional-clear';
|
||||
*/
|
||||
export const SAVE_VERSION_MESSAGE_TYPE = 'save-version';
|
||||
|
||||
/**
|
||||
* #370 F2 — wire format of the server→client REPLY to a save-version signal, sent
|
||||
* over the same stateless channel. `version.saved` means a version was created or
|
||||
* promoted; `version.skipped` is a TERMINAL "nothing was pinned" reply for the two
|
||||
* reachable no-op cases (an effectively-empty page, or the page row is gone) so the
|
||||
* client resolves at once instead of waiting out its ack timeout and misreporting a
|
||||
* healthy server as unreachable. EXACTLY ONE of these is broadcast per handled
|
||||
* save. The MCP client duplicates these literals (it cannot import server code) —
|
||||
* keep the two in sync (see packages/mcp/src/lib/collaboration.ts).
|
||||
*/
|
||||
export const VERSION_SAVED_MESSAGE_TYPE = 'version.saved';
|
||||
export const VERSION_SKIPPED_MESSAGE_TYPE = 'version.skipped';
|
||||
|
||||
/**
|
||||
* #251 — how long an intentional-clear signal stays "pending" before it is
|
||||
* ignored. The signal is set on the clearing keystroke but consumed by the
|
||||
@@ -656,10 +643,6 @@ export class PersistenceExtension implements Extension {
|
||||
let result:
|
||||
| { historyId: string; kind: PageHistoryKind; alreadySaved: boolean }
|
||||
| undefined;
|
||||
// #370 F2 — set when there is nothing to version (empty page / page gone), so
|
||||
// the tail broadcasts a terminal `version.skipped` instead of staying silent
|
||||
// and forcing the client to time out. Mutually exclusive with `result`.
|
||||
let skipped: 'empty' | 'page-not-found' | undefined;
|
||||
|
||||
// #370 F8-twin — the contributor set popped from Redis (destructive SPOP)
|
||||
// must be restored if the version row does not durably land. The inner
|
||||
@@ -685,20 +668,11 @@ export class PersistenceExtension implements Extension {
|
||||
includeContent: true,
|
||||
trx,
|
||||
});
|
||||
if (!page) {
|
||||
// The page row is gone (deleted/never persisted). Nothing to version —
|
||||
// record it so the tail sends a terminal skip reply (#370 F2).
|
||||
skipped = 'page-not-found';
|
||||
return;
|
||||
}
|
||||
if (!page) return;
|
||||
versionedPageId = page.id;
|
||||
// Never version an effectively-empty page (mirrors the processor's
|
||||
// first-history guard); there is nothing intentional to pin. Record the
|
||||
// skip so the client gets a terminal reply rather than a timeout (#370 F2).
|
||||
if (isEmptyParagraphDoc(page.content as any)) {
|
||||
skipped = 'empty';
|
||||
return;
|
||||
}
|
||||
// first-history guard); there is nothing intentional to pin.
|
||||
if (isEmptyParagraphDoc(page.content as any)) return;
|
||||
|
||||
const lastHistory = await this.pageHistoryRepo.findPageLastHistory(
|
||||
page.id,
|
||||
@@ -773,26 +747,15 @@ export class PersistenceExtension implements Extension {
|
||||
}
|
||||
this.idleBurstStart.delete(documentName);
|
||||
|
||||
// EXACTLY ONE terminal reply per handled save (#370 F2): a real save/promote
|
||||
// broadcasts `version.saved`; the two no-op early returns (empty page / page
|
||||
// gone) broadcast `version.skipped` so the client never waits out its ack
|
||||
// timeout. A genuine failure threw above and rejected before reaching here.
|
||||
if (result) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: VERSION_SAVED_MESSAGE_TYPE,
|
||||
type: 'version.saved',
|
||||
historyId: result.historyId,
|
||||
kind: result.kind,
|
||||
alreadySaved: result.alreadySaved,
|
||||
}),
|
||||
);
|
||||
} else if (skipped) {
|
||||
document.broadcastStateless(
|
||||
JSON.stringify({
|
||||
type: VERSION_SKIPPED_MESSAGE_TYPE,
|
||||
reason: skipped,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,14 +32,6 @@ 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.
|
||||
@@ -116,14 +108,6 @@ 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),
|
||||
@@ -190,7 +174,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.resolveEmbeddingProvider = jest
|
||||
aiService.getEmbeddingModel = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new AiEmbeddingNotConfiguredException());
|
||||
|
||||
@@ -203,87 +187,3 @@ 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,24 +108,15 @@ export class EmbeddingIndexerService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Resolve embeddings config WITHOUT crashing the queue when unconfigured.
|
||||
let modelName = 'unknown';
|
||||
let provider: Awaited<
|
||||
ReturnType<AiService['resolveEmbeddingProvider']>
|
||||
>;
|
||||
try {
|
||||
provider = await this.aiService.resolveEmbeddingProvider(workspaceId);
|
||||
const model = await this.aiService.getEmbeddingModel(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 provider.model === 'string'
|
||||
? provider.model
|
||||
: (provider.model.modelId ?? 'unknown');
|
||||
typeof model === 'string' ? model : (model.modelId ?? 'unknown');
|
||||
} catch (err) {
|
||||
if (err instanceof AiEmbeddingNotConfiguredException) {
|
||||
// No embeddings provider for this workspace: NO-OP (§6.7). The page can
|
||||
@@ -154,18 +145,8 @@ export class EmbeddingIndexerService {
|
||||
return;
|
||||
}
|
||||
|
||||
// #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,
|
||||
);
|
||||
// Embed all chunks in one batch.
|
||||
const vectors = await this.aiService.embedTexts(workspaceId, chunks);
|
||||
|
||||
// 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
|
||||
@@ -189,7 +170,6 @@ export class EmbeddingIndexerService {
|
||||
vectors,
|
||||
{ pageId, workspaceId, spaceId },
|
||||
modelName,
|
||||
provider.fingerprint,
|
||||
);
|
||||
|
||||
// HARD replace in one transaction: delete then insert so search never
|
||||
@@ -236,9 +216,7 @@ export class EmbeddingIndexerService {
|
||||
// (seeded at enqueue time); the finally cleans that too.
|
||||
try {
|
||||
try {
|
||||
// #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);
|
||||
await this.aiService.getEmbeddingModel(workspaceId);
|
||||
} catch (err) {
|
||||
if (err instanceof AiEmbeddingNotConfiguredException) {
|
||||
this.logger.log(
|
||||
@@ -370,7 +348,6 @@ export class EmbeddingIndexerService {
|
||||
vectors: number[][],
|
||||
ids: { pageId: string; workspaceId: string; spaceId: string },
|
||||
modelName: string,
|
||||
fingerprint: string | null,
|
||||
): PageEmbeddingChunkRow[] {
|
||||
const rows: PageEmbeddingChunkRow[] = [];
|
||||
let cursor = 0;
|
||||
@@ -393,8 +370,6 @@ 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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,7 +106,6 @@ function __assertClientCallContract(client: DocmostClientLike): void {
|
||||
void client.sharePage(s, true);
|
||||
void client.unsharePage(s);
|
||||
void client.restorePageVersion(s);
|
||||
void client.savePageVersion(s);
|
||||
void client.transformPage(s, s, { dryRun: true });
|
||||
void client.stashPage(s);
|
||||
// --- write (image / footnote), in-app since #410 ---
|
||||
|
||||
@@ -58,7 +58,6 @@ type DocmostClientMethod =
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'savePageVersion'
|
||||
| 'transformPage'
|
||||
| 'stashPage'
|
||||
// --- write (image / footnote), in-app since #410 ---
|
||||
|
||||
@@ -210,6 +210,55 @@ describe('PublicShareChatToolsService.forShare', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharePage under approved publication (owner decision: assistant sees the frozen saved version)', () => {
|
||||
it('reads the FROZEN saved content/title returned by the boundary, not a live re-fetch', async () => {
|
||||
// #370 Stage B owner decision: ALL public reads — including the share
|
||||
// assistant's page-read tool — see the published saved version, so a
|
||||
// visitor and the assistant see the same bytes. The content swap lives in
|
||||
// resolveReadableSharePage (the single canonical boundary), so the tool
|
||||
// simply consumes the frozen { page } it returns: the assistant must never
|
||||
// re-read the live draft behind the boundary's back.
|
||||
const frozenContent = {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ type: 'text', text: 'FROZEN saved version body' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
// The boundary already swapped content+title to the manual version while
|
||||
// preserving the live id (its unit test pins that); the tool sees this.
|
||||
const frozenPage = {
|
||||
id: 'page-1',
|
||||
title: 'Saved Version Title',
|
||||
deletedAt: null,
|
||||
content: frozenContent,
|
||||
};
|
||||
|
||||
const { svc, shareService } = makeService({
|
||||
resolveReadableSharePage: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ share: { id: 'SHARE-A' }, page: frozenPage }),
|
||||
});
|
||||
// updatePublicAttachments sanitizes the FROZEN page it was handed.
|
||||
shareService.updatePublicAttachments.mockResolvedValue(frozenContent);
|
||||
|
||||
const tools = svc.forShare('SHARE-A', 'ws-1');
|
||||
const out = (await (tools.getSharePage as unknown as ToolExec).execute({
|
||||
pageId: 'page-1',
|
||||
})) as { title: string; markdown: string };
|
||||
|
||||
// Sanitizer is handed the frozen page (never a separate live fetch).
|
||||
expect(shareService.updatePublicAttachments).toHaveBeenCalledWith(
|
||||
frozenPage,
|
||||
);
|
||||
// Title + body come from the saved (frozen) version.
|
||||
expect(out.title).toBe('Saved Version Title');
|
||||
expect(out.markdown).toContain('FROZEN saved version body');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSharePage non-resolving page (deleted / restricted / out-of-share)', () => {
|
||||
it('resolveReadableSharePage returns null (e.g. soft-deleted page) => generic error, NO content sanitized/returned', async () => {
|
||||
// The canonical boundary 404s a soft-deleted / restricted / out-of-tree
|
||||
|
||||
@@ -32,27 +32,10 @@ export class SearchResultDto {
|
||||
matchedTerms: string[];
|
||||
}
|
||||
|
||||
// #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.
|
||||
// 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.
|
||||
export class SearchResponseDto {
|
||||
items: SearchResultDto[];
|
||||
total: number;
|
||||
@@ -70,7 +53,4 @@ 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,15 +1,8 @@
|
||||
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,10 +48,6 @@ 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,8 +11,6 @@ describe('SearchService', () => {
|
||||
{} as any, // shareRepo
|
||||
{} as any, // spaceMemberRepo
|
||||
{} as any, // pagePermissionRepo
|
||||
{} as any, // aiService
|
||||
{} as any, // pageEmbeddingRepo
|
||||
);
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
@@ -63,8 +61,6 @@ 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 };
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable } 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';
|
||||
@@ -12,10 +11,6 @@ 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,
|
||||
@@ -64,42 +59,14 @@ 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) =====
|
||||
@@ -325,102 +292,33 @@ 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). -----------------------
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
|
||||
const semantic: SearchSemanticDto = {
|
||||
state: semanticAvailable ? 'full' : 'off',
|
||||
available: semanticAvailable,
|
||||
...(semanticReason ? { reason: semanticReason } : {}),
|
||||
};
|
||||
let orderedIds = rankedRows.rows.map((r) => r.id);
|
||||
|
||||
// --- Permission filter (fail-closed, exact total). ------------------------
|
||||
// filterAccessiblePageIds runs the #348 hasRestricted pre-check internally:
|
||||
@@ -457,131 +355,14 @@ export class SearchService {
|
||||
const hasMore = offset + pageIds.length < window.length;
|
||||
|
||||
if (pageIds.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
total,
|
||||
hasMore,
|
||||
truncatedAtCap,
|
||||
offset,
|
||||
query: queryMeta,
|
||||
semantic,
|
||||
};
|
||||
return { items: [], total, hasMore, truncatedAtCap, offset, query: queryMeta };
|
||||
}
|
||||
|
||||
// --- 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,
|
||||
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);
|
||||
});
|
||||
return { items, total, hasMore, truncatedAtCap, offset, query: queryMeta };
|
||||
}
|
||||
|
||||
// Resolve the search scope: explicit space, the authed user's member spaces, or
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
@@ -18,6 +19,13 @@ export class CreateShareDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
searchIndexing: boolean;
|
||||
|
||||
// #370 Stage B — publication mode. 'live' (default) serves the current draft;
|
||||
// 'approved' serves the last manually-saved version (page_history.kind
|
||||
// ='manual'). Mutually exclusive with includeSubPages (enforced server-side).
|
||||
@IsOptional()
|
||||
@IsIn(['live', 'approved'])
|
||||
publishedMode?: 'live' | 'approved';
|
||||
}
|
||||
|
||||
export class UpdateShareDto extends CreateShareDto {
|
||||
|
||||
@@ -39,6 +39,7 @@ function buildService() {
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ function buildService(over: {
|
||||
{} as any, // tokenService
|
||||
{} as any, // transclusionService
|
||||
{} as any, // workspaceRepo
|
||||
{} as any, // pageHistoryRepo
|
||||
);
|
||||
|
||||
jest
|
||||
|
||||
@@ -62,6 +62,7 @@ function buildService(opts: {
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
|
||||
// getSharedPage resolves the share via getShareForPage (a raw db query).
|
||||
@@ -181,6 +182,7 @@ describe('ShareService.lookupTransclusionForShare htmlEmbed kill-switch (real co
|
||||
tokenService as any,
|
||||
transclusionService as any,
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
|
||||
// isSharingAllowed and getShareForPage hit the raw db; stub them so the
|
||||
|
||||
@@ -23,6 +23,9 @@ function buildService(over: {
|
||||
resolvedShare?: unknown;
|
||||
page?: unknown;
|
||||
restricted?: boolean;
|
||||
// #370 Stage B — the latest manual page-history row returned for the approved
|
||||
// swap. `undefined` (the default) means the repo finds no manual version.
|
||||
manualHistory?: unknown;
|
||||
} = {}) {
|
||||
const pageRepo = {
|
||||
findById: jest.fn(async () =>
|
||||
@@ -34,6 +37,9 @@ function buildService(over: {
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: jest.fn(async () => over.restricted ?? false),
|
||||
};
|
||||
const pageHistoryRepo = {
|
||||
findLatestByPageIdAndKind: jest.fn(async () => over.manualHistory ?? undefined),
|
||||
};
|
||||
|
||||
const service = new ShareService(
|
||||
{} as any, // shareRepo (unused on this path)
|
||||
@@ -43,6 +49,7 @@ function buildService(over: {
|
||||
{} as any, // tokenService (unused)
|
||||
{} as any, // transclusionService (unused)
|
||||
{} as any, // workspaceRepo (unused)
|
||||
pageHistoryRepo as any,
|
||||
);
|
||||
|
||||
jest
|
||||
@@ -53,7 +60,7 @@ function buildService(over: {
|
||||
: { id: SHARE, pageId: PAGE, spaceId: 'space-1' }) as any,
|
||||
);
|
||||
|
||||
return { service, pageRepo, pagePermissionRepo };
|
||||
return { service, pageRepo, pagePermissionRepo, pageHistoryRepo };
|
||||
}
|
||||
|
||||
describe('ShareService.resolveReadableSharePage (the share-access boundary)', () => {
|
||||
@@ -133,4 +140,90 @@ describe('ShareService.resolveReadableSharePage (the share-access boundary)', ()
|
||||
includeCreator: true,
|
||||
});
|
||||
});
|
||||
|
||||
// #370 Stage B — "approved" publication freezes the served content/title to
|
||||
// the latest manual page-history version, WITHOUT touching page.id or
|
||||
// page.workspaceId (downstream attachment tokens are minted off those, so
|
||||
// freezing them would mint tokens for the wrong owner).
|
||||
describe('approved publication mode (content freeze)', () => {
|
||||
const LIVE_PAGE = {
|
||||
id: PAGE,
|
||||
workspaceId: WS,
|
||||
deletedAt: null,
|
||||
title: 'Live draft title',
|
||||
content: { type: 'doc', live: true },
|
||||
};
|
||||
const MANUAL = {
|
||||
title: 'Saved version title',
|
||||
content: { type: 'doc', saved: true },
|
||||
};
|
||||
|
||||
it('swaps content + title to the latest manual version but PRESERVES id + workspaceId', async () => {
|
||||
const { service, pageHistoryRepo } = buildService({
|
||||
resolvedShare: {
|
||||
id: SHARE,
|
||||
pageId: PAGE,
|
||||
spaceId: 'space-1',
|
||||
publishedMode: 'approved',
|
||||
},
|
||||
page: { ...LIVE_PAGE },
|
||||
manualHistory: MANUAL,
|
||||
});
|
||||
|
||||
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
// Content + title come from the saved version.
|
||||
expect(out!.page.content).toEqual(MANUAL.content);
|
||||
expect(out!.page.title).toBe(MANUAL.title);
|
||||
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
|
||||
expect(out!.page.id).toBe(LIVE_PAGE.id);
|
||||
expect(out!.page.workspaceId).toBe(LIVE_PAGE.workspaceId);
|
||||
// Resolved against the LIVE page id and the manual tier, with content.
|
||||
expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalledWith(
|
||||
LIVE_PAGE.id,
|
||||
'manual',
|
||||
{ includeContent: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to live content when no manual version exists yet', async () => {
|
||||
const { service, pageHistoryRepo } = buildService({
|
||||
resolvedShare: {
|
||||
id: SHARE,
|
||||
pageId: PAGE,
|
||||
spaceId: 'space-1',
|
||||
publishedMode: 'approved',
|
||||
},
|
||||
page: { ...LIVE_PAGE },
|
||||
// manualHistory omitted → repo returns undefined
|
||||
});
|
||||
|
||||
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.page.content).toEqual(LIVE_PAGE.content);
|
||||
expect(out!.page.title).toBe(LIVE_PAGE.title);
|
||||
expect(pageHistoryRepo.findLatestByPageIdAndKind).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT consult page-history for a live share', async () => {
|
||||
const { service, pageHistoryRepo } = buildService({
|
||||
resolvedShare: {
|
||||
id: SHARE,
|
||||
pageId: PAGE,
|
||||
spaceId: 'space-1',
|
||||
publishedMode: 'live',
|
||||
},
|
||||
page: { ...LIVE_PAGE },
|
||||
manualHistory: MANUAL,
|
||||
});
|
||||
|
||||
const out = await service.resolveReadableSharePage(SHARE, PAGE, WS);
|
||||
|
||||
expect(out).not.toBeNull();
|
||||
expect(out!.page.content).toEqual(LIVE_PAGE.content);
|
||||
expect(pageHistoryRepo.findLatestByPageIdAndKind).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ function buildService() {
|
||||
tokenService as any,
|
||||
{} as any, // transclusionService (unused)
|
||||
workspaceRepo as any,
|
||||
{} as any, // pageHistoryRepo (unused on this path)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../common/helpers/prosemirror/utils';
|
||||
import { Node } from '@tiptap/pm/model';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||
import { updateAttachmentAttr } from './share.util';
|
||||
import { Page } from '@docmost/db/types/entity.types';
|
||||
@@ -44,6 +45,7 @@ export class ShareService {
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly transclusionService: TransclusionService,
|
||||
private readonly workspaceRepo: WorkspaceRepo,
|
||||
private readonly pageHistoryRepo: PageHistoryRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -92,21 +94,39 @@ export class ShareService {
|
||||
}) {
|
||||
const { authUserId, workspaceId, page, createShareDto } = opts;
|
||||
|
||||
const includeSubPages = createShareDto.includeSubPages ?? false;
|
||||
const publishedMode = createShareDto.publishedMode ?? 'live';
|
||||
|
||||
// #370 Stage B — 'approved' freezes a SINGLE page to its saved version; a
|
||||
// sub-tree cannot be version-frozen in the MVP, so the two are mutually
|
||||
// exclusive. Thrown before the try so the specific 400 reaches the client
|
||||
// instead of being flattened to the generic "Failed to share page".
|
||||
this.assertPublishedModeCompatible(publishedMode, includeSubPages);
|
||||
|
||||
try {
|
||||
const shares = await this.shareRepo.findByPageId(page.id);
|
||||
if (shares) {
|
||||
return shares;
|
||||
}
|
||||
|
||||
return await this.shareRepo.insertShare({
|
||||
const share = await this.shareRepo.insertShare({
|
||||
key: nanoIdGen().toLowerCase(),
|
||||
pageId: page.id,
|
||||
includeSubPages: createShareDto.includeSubPages ?? false,
|
||||
includeSubPages,
|
||||
searchIndexing: createShareDto.searchIndexing ?? false,
|
||||
publishedMode,
|
||||
creatorId: authUserId,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
});
|
||||
|
||||
// Guarantee an approved share always has a saved version to serve: mint
|
||||
// the first manual baseline from the page's current content on enable.
|
||||
if (publishedMode === 'approved') {
|
||||
await this.ensureApprovedBaseline(page.id);
|
||||
}
|
||||
|
||||
return share;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to share page');
|
||||
@@ -114,20 +134,84 @@ export class ShareService {
|
||||
}
|
||||
|
||||
async updateShare(shareId: string, updateShareDto: UpdateShareDto) {
|
||||
// Resolve the current share so the XOR gate can be evaluated against the
|
||||
// EFFECTIVE post-update state (either field may be absent from the DTO).
|
||||
const current = await this.shareRepo.findById(shareId);
|
||||
if (!current) {
|
||||
throw new NotFoundException('Share not found');
|
||||
}
|
||||
|
||||
const effectiveIncludeSubPages =
|
||||
updateShareDto.includeSubPages ?? current.includeSubPages ?? false;
|
||||
const effectivePublishedMode = (updateShareDto.publishedMode ??
|
||||
current.publishedMode ??
|
||||
'live') as 'live' | 'approved';
|
||||
|
||||
// #370 Stage B — enforce the approved/includeSubPages XOR on the merged
|
||||
// state. Thrown before the try so the specific 400 reaches the client.
|
||||
this.assertPublishedModeCompatible(
|
||||
effectivePublishedMode,
|
||||
effectiveIncludeSubPages,
|
||||
);
|
||||
|
||||
try {
|
||||
return this.shareRepo.updateShare(
|
||||
const updated = await this.shareRepo.updateShare(
|
||||
{
|
||||
includeSubPages: updateShareDto.includeSubPages,
|
||||
searchIndexing: updateShareDto.searchIndexing,
|
||||
publishedMode: updateShareDto.publishedMode,
|
||||
},
|
||||
shareId,
|
||||
);
|
||||
|
||||
// On (or while) enabling approved mode, ensure a manual baseline exists so
|
||||
// public readers always have a saved version to serve. Idempotent: a no-op
|
||||
// when the page already has a manual history row.
|
||||
if (effectivePublishedMode === 'approved') {
|
||||
await this.ensureApprovedBaseline(current.pageId);
|
||||
}
|
||||
|
||||
return updated;
|
||||
} catch (err) {
|
||||
this.logger.error(err);
|
||||
throw new BadRequestException('Failed to update share');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #370 Stage B — 'approved' publication is mutually exclusive with
|
||||
* includeSubPages: a whole sub-tree cannot be version-frozen in the MVP.
|
||||
*/
|
||||
private assertPublishedModeCompatible(
|
||||
publishedMode: 'live' | 'approved',
|
||||
includeSubPages: boolean,
|
||||
): void {
|
||||
if (publishedMode === 'approved' && includeSubPages) {
|
||||
throw new BadRequestException(
|
||||
'Approved publication cannot be combined with including sub-pages',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* #370 Stage B — guarantee an approved share has a saved version to serve.
|
||||
* When the page has NO manual history row yet, snapshot its CURRENT content as
|
||||
* the first manual version. Idempotent: returns early when a manual version
|
||||
* already exists, so it is safe to call on every approved update.
|
||||
*/
|
||||
private async ensureApprovedBaseline(pageId: string): Promise<void> {
|
||||
const existing = await this.pageHistoryRepo.findLatestByPageIdAndKind(
|
||||
pageId,
|
||||
'manual',
|
||||
);
|
||||
if (existing) return;
|
||||
|
||||
const page = await this.pageRepo.findById(pageId, { includeContent: true });
|
||||
if (!page) return;
|
||||
|
||||
await this.pageHistoryRepo.saveHistory(page, { kind: 'manual' });
|
||||
}
|
||||
|
||||
/**
|
||||
* THE share access boundary in ONE place.
|
||||
*
|
||||
@@ -173,7 +257,7 @@ export class ShareService {
|
||||
// passes no shareId (it resolved the share from the page itself).
|
||||
if (shareId != null && share.id !== shareId) return null;
|
||||
|
||||
const page = await this.pageRepo.findById(pageId, {
|
||||
let page = await this.pageRepo.findById(pageId, {
|
||||
includeContent: true,
|
||||
includeCreator: opts?.includeCreator ?? false,
|
||||
});
|
||||
@@ -185,6 +269,30 @@ export class ShareService {
|
||||
return null;
|
||||
}
|
||||
|
||||
// #370 Stage B — "approved" publication freezes what public readers see to
|
||||
// the LAST manually-saved version (page_history.kind='manual'), not the live
|
||||
// draft. This is the SINGLE canonical resolver every public read funnels
|
||||
// through (the shared-page view, the SEO controller, AND the share
|
||||
// assistant's page-read tool), so the swap here freezes ALL of them
|
||||
// consistently — a public visitor and the assistant see the same bytes.
|
||||
//
|
||||
// Swap ONLY content + title. page.id and page.workspaceId stay LIVE on
|
||||
// purpose: downstream updatePublicAttachments mints per-attachment tokens
|
||||
// off page.id/workspaceId, so freezing those would mint tokens for the
|
||||
// wrong owner. The SEO controller reads resolved.page.title, so the frozen
|
||||
// title flows through automatically. If no manual version exists yet (rare —
|
||||
// enabling an approved share auto-creates a baseline), fall back to live.
|
||||
if (share.publishedMode === 'approved') {
|
||||
const hist = await this.pageHistoryRepo.findLatestByPageIdAndKind(
|
||||
page.id,
|
||||
'manual',
|
||||
{ includeContent: true },
|
||||
);
|
||||
if (hist) {
|
||||
page = { ...page, content: hist.content, title: hist.title };
|
||||
}
|
||||
}
|
||||
|
||||
return { share, page };
|
||||
}
|
||||
|
||||
@@ -302,6 +410,7 @@ export class ShareService {
|
||||
'shares.key as shareKey',
|
||||
'shares.includeSubPages',
|
||||
'shares.searchIndexing',
|
||||
'shares.publishedMode',
|
||||
'shares.creatorId',
|
||||
'shares.spaceId',
|
||||
'shares.workspaceId',
|
||||
@@ -326,6 +435,7 @@ export class ShareService {
|
||||
's.key as shareKey',
|
||||
's.includeSubPages',
|
||||
's.searchIndexing',
|
||||
's.publishedMode',
|
||||
's.creatorId',
|
||||
's.spaceId',
|
||||
's.workspaceId',
|
||||
@@ -355,6 +465,7 @@ export class ShareService {
|
||||
key: share.shareKey,
|
||||
includeSubPages: share.includeSubPages,
|
||||
searchIndexing: share.searchIndexing,
|
||||
publishedMode: share.publishedMode,
|
||||
pageId: share.id,
|
||||
creatorId: share.creatorId,
|
||||
spaceId: share.spaceId,
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { type Kysely, sql } from 'kysely';
|
||||
|
||||
/**
|
||||
* #370 Stage B — share "approved" (publish-only-the-saved-version) mode.
|
||||
*
|
||||
* Adds `shares.published_mode`, the per-share switch between:
|
||||
* - 'live' — public readers see the current draft (legacy behavior)
|
||||
* - 'approved' — public readers see the LAST manually-saved version
|
||||
* (page_history.kind='manual'), not the live draft
|
||||
*
|
||||
* NOT NULL with a 'live' default so every existing share keeps its current
|
||||
* (live) semantics with no data backfill. Standalone + single-concern +
|
||||
* additive per the #363 crash-loop rule — no coupled baseline write here;
|
||||
* the first manual baseline is minted by the service on enable, not by this
|
||||
* migration. Stored as a short varchar to stay forward-compatible without an
|
||||
* enum migration.
|
||||
*/
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.alterTable('shares')
|
||||
.addColumn('published_mode', 'varchar(20)', (col) =>
|
||||
col.notNull().defaultTo(sql`'live'`),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.alterTable('shares').dropColumn('published_mode').execute();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { RawBuilder, sql } from 'kysely';
|
||||
import { sql } from 'kysely';
|
||||
import * as pgvector from 'pgvector';
|
||||
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
||||
import { dbOrTx } from '../../utils';
|
||||
@@ -35,9 +35,6 @@ 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[];
|
||||
}
|
||||
|
||||
@@ -123,7 +120,6 @@ 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`,
|
||||
})),
|
||||
@@ -131,82 +127,6 @@ 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
|
||||
|
||||
@@ -219,6 +219,36 @@ export class PageHistoryRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* #370 Stage B — resolve the latest snapshot of a page at a given
|
||||
* intentionality tier. Used by the "approved" share mode to serve the last
|
||||
* manually-saved version (`kind='manual'`) to public readers instead of the
|
||||
* live draft. Modeled on `findPageLastHistory` with an added `kind` filter and
|
||||
* the same deterministic `(createdAt desc, id desc)` tie-break, so two rows
|
||||
* sharing a createdAt still resolve to a single stable "latest".
|
||||
*/
|
||||
async findLatestByPageIdAndKind(
|
||||
pageId: string,
|
||||
kind: PageHistoryKind,
|
||||
opts?: {
|
||||
includeContent?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
},
|
||||
) {
|
||||
const db = dbOrTx(this.db, opts?.trx);
|
||||
|
||||
return await db
|
||||
.selectFrom('pageHistory')
|
||||
.select(this.baseFields)
|
||||
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||
.where('pageId', '=', pageId)
|
||||
.where('kind', '=', kind)
|
||||
.limit(1)
|
||||
.orderBy('createdAt', 'desc')
|
||||
.orderBy('id', 'desc')
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
withLastUpdatedBy(eb: ExpressionBuilder<DB, 'pageHistory'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
@@ -28,6 +28,7 @@ export class ShareRepo {
|
||||
'pageId',
|
||||
'includeSubPages',
|
||||
'searchIndexing',
|
||||
'publishedMode',
|
||||
'creatorId',
|
||||
'spaceId',
|
||||
'workspaceId',
|
||||
|
||||
+1
@@ -340,6 +340,7 @@ export interface Shares {
|
||||
includeSubPages: Generated<boolean | null>;
|
||||
key: string;
|
||||
pageId: string | null;
|
||||
publishedMode: Generated<string>;
|
||||
searchIndexing: Generated<boolean | null>;
|
||||
spaceId: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
|
||||
@@ -7,11 +7,6 @@ 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;
|
||||
|
||||
@@ -108,23 +108,6 @@ 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
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -23,50 +23,6 @@ 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.
|
||||
@@ -406,112 +362,11 @@ export class AiService {
|
||||
async embedTexts(workspaceId: string, texts: string[]): Promise<number[][]> {
|
||||
if (texts.length === 0) return [];
|
||||
const model = await this.getEmbeddingModel(workspaceId);
|
||||
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 [];
|
||||
// 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();
|
||||
const signal = AbortSignal.timeout(timeoutMs);
|
||||
try {
|
||||
const { embeddings } = await embedMany({
|
||||
@@ -536,8 +391,8 @@ export class AiService {
|
||||
if (signal.aborted && abortLike) {
|
||||
throw new Error(
|
||||
`Embedding request timed out after ${timeoutMs}ms ` +
|
||||
`(workspace ${workspaceId}, ${texts.length} value(s)). ` +
|
||||
`Increase the embedding timeout or check the embeddings endpoint.`,
|
||||
`(workspace ${workspaceId}, ${texts.length} chunk(s)). ` +
|
||||
`Increase AI_EMBEDDING_TIMEOUT_MS or check the embeddings endpoint.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
@@ -553,16 +408,6 @@ 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 {
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
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,
|
||||
@@ -56,19 +53,10 @@ 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 = {
|
||||
@@ -83,86 +71,15 @@ 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;
|
||||
|
||||
@@ -692,324 +609,4 @@ 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ShareService } from 'src/core/share/share.service';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
|
||||
import * as publishedModeMigration from 'src/database/migrations/20260712T130000-shares-published-mode';
|
||||
import {
|
||||
getTestDb,
|
||||
destroyTestDb,
|
||||
createWorkspace,
|
||||
createSpace,
|
||||
createPage,
|
||||
} from './db';
|
||||
|
||||
/**
|
||||
* #370 Stage B — "approved" (publish-only-the-saved-version) share mode, against
|
||||
* real Postgres (so the migration column + the hand-written recursive-CTE
|
||||
* threading in getShareForPage are exercised for real, not mocked).
|
||||
*
|
||||
* Guards:
|
||||
* - getShareForPage carries `publishedMode` through ALL of the CTE points
|
||||
* (anchor select, recursive-union select, returned object) — a direct share
|
||||
* AND a descendant (union) share both read it back. If ANY of the 4 threading
|
||||
* points is dropped, one of these reddens.
|
||||
* - createShare / updateShare auto-mint the first manual baseline ON ENABLE.
|
||||
* - approved + includeSubPages is rejected (BadRequestException) on both paths.
|
||||
* - resolveReadableSharePage under approved swaps content+title to the manual
|
||||
* version while PRESERVING page.id + workspaceId.
|
||||
*/
|
||||
describe('share published_mode (approved) [integration]', () => {
|
||||
let db: Kysely<any>;
|
||||
let service: ShareService;
|
||||
let shareRepo: ShareRepo;
|
||||
let pageHistoryRepo: PageHistoryRepo;
|
||||
let wsId: string;
|
||||
let spaceId: string;
|
||||
|
||||
// shares.creator_id / page_history.last_updated_by_id are uuid columns; use
|
||||
// null (nullable) rather than a synthetic non-uuid id.
|
||||
const USER = null as any;
|
||||
|
||||
// resolveReadableSharePage runs the restricted-ancestor gate; keep it open.
|
||||
const pagePermissionRepo = {
|
||||
hasRestrictedAncestor: async () => false,
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
db = getTestDb();
|
||||
shareRepo = new ShareRepo(db as any, {} as any);
|
||||
const pageRepo = new PageRepo(db as any, {} as any, {} as any);
|
||||
pageHistoryRepo = new PageHistoryRepo(db as any);
|
||||
service = new ShareService(
|
||||
shareRepo as any,
|
||||
pageRepo as any,
|
||||
pagePermissionRepo as any,
|
||||
db as any,
|
||||
{} as any, // tokenService — no attachments in these docs
|
||||
{} as any, // transclusionService
|
||||
{ findById: async () => ({ settings: {} }) } as any, // workspaceRepo
|
||||
pageHistoryRepo as any,
|
||||
);
|
||||
wsId = (await createWorkspace(db)).id;
|
||||
spaceId = (await createSpace(db, wsId)).id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await destroyTestDb();
|
||||
});
|
||||
|
||||
const docWith = (text: string) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{ type: 'paragraph', content: [{ type: 'text', text }] },
|
||||
],
|
||||
});
|
||||
|
||||
const newPage = async (
|
||||
text = 'live body',
|
||||
parentPageId?: string,
|
||||
): Promise<string> => {
|
||||
const { id } = await createPage(db, { workspaceId: wsId, spaceId });
|
||||
await db
|
||||
.updateTable('pages')
|
||||
.set({ content: docWith(text) as any, parentPageId: parentPageId ?? null })
|
||||
.where('id', '=', id)
|
||||
.execute();
|
||||
return id;
|
||||
};
|
||||
|
||||
const insertShare = async (args: {
|
||||
pageId: string;
|
||||
publishedMode?: string;
|
||||
includeSubPages?: boolean;
|
||||
}) => {
|
||||
const id = randomUUID();
|
||||
await db
|
||||
.insertInto('shares')
|
||||
.values({
|
||||
id,
|
||||
key: `k-${id.slice(0, 12)}`,
|
||||
pageId: args.pageId,
|
||||
includeSubPages: args.includeSubPages ?? false,
|
||||
publishedMode: args.publishedMode ?? 'live',
|
||||
creatorId: USER,
|
||||
spaceId,
|
||||
workspaceId: wsId,
|
||||
})
|
||||
.execute();
|
||||
return id;
|
||||
};
|
||||
|
||||
const manualRows = (pageId: string) =>
|
||||
db
|
||||
.selectFrom('pageHistory')
|
||||
.select(['id', 'kind', 'title'])
|
||||
.where('pageId', '=', pageId)
|
||||
.where('kind', '=', 'manual')
|
||||
.execute();
|
||||
|
||||
const publishedModeColumn = async () =>
|
||||
db
|
||||
.selectFrom('information_schema.columns' as any)
|
||||
.select(['columnName', 'dataType', 'isNullable', 'columnDefault'] as any)
|
||||
.where('tableName' as any, '=', 'shares')
|
||||
.where('columnName' as any, '=', 'published_mode')
|
||||
.executeTakeFirst();
|
||||
|
||||
// --- migration up/down round-trip ----------------------------------------
|
||||
|
||||
it('migration down() drops published_mode; up() re-adds it NOT NULL default live', async () => {
|
||||
// global-setup already migrated up, so the column is present.
|
||||
const before: any = await publishedModeColumn();
|
||||
expect(before).toBeDefined();
|
||||
expect(before.isNullable).toBe('NO');
|
||||
expect(String(before.columnDefault)).toContain("'live'");
|
||||
|
||||
await publishedModeMigration.down(db as any);
|
||||
expect(await publishedModeColumn()).toBeUndefined();
|
||||
|
||||
// Restore so the rest of the suite (and afterAll) sees the real schema.
|
||||
await publishedModeMigration.up(db as any);
|
||||
const after: any = await publishedModeColumn();
|
||||
expect(after).toBeDefined();
|
||||
expect(after.isNullable).toBe('NO');
|
||||
});
|
||||
|
||||
// --- getShareForPage 4-point threading -----------------------------------
|
||||
|
||||
it('getShareForPage carries publishedMode for a DIRECT share (anchor select + returned object)', async () => {
|
||||
const pageId = await newPage();
|
||||
await insertShare({ pageId, publishedMode: 'approved' });
|
||||
|
||||
const share = await service.getShareForPage(pageId, wsId);
|
||||
expect(share).toBeDefined();
|
||||
expect(share!.publishedMode).toBe('approved');
|
||||
});
|
||||
|
||||
it('getShareForPage carries publishedMode for a DESCENDANT share (recursive-union select)', async () => {
|
||||
// Parent shared with includeSubPages + approved (inserted directly, bypassing
|
||||
// the service XOR gate, purely to exercise the CTE union threading on a
|
||||
// child that inherits the ancestor share).
|
||||
const parentId = await newPage('parent body');
|
||||
await insertShare({
|
||||
pageId: parentId,
|
||||
publishedMode: 'approved',
|
||||
includeSubPages: true,
|
||||
});
|
||||
const childId = await newPage('child body', parentId);
|
||||
|
||||
const share = await service.getShareForPage(childId, wsId);
|
||||
expect(share).toBeDefined();
|
||||
// Resolved via the recursive union up to the ancestor share.
|
||||
expect((share!.level as number) > 0).toBe(true);
|
||||
expect(share!.publishedMode).toBe('approved');
|
||||
});
|
||||
|
||||
// --- baseline auto-create on enable --------------------------------------
|
||||
|
||||
it('createShare(approved) mints the first manual baseline from current content', async () => {
|
||||
const pageId = await newPage('baseline body');
|
||||
const page = { id: pageId, spaceId } as any;
|
||||
|
||||
expect(await manualRows(pageId)).toHaveLength(0);
|
||||
|
||||
await service.createShare({
|
||||
authUserId: USER,
|
||||
workspaceId: wsId,
|
||||
page,
|
||||
createShareDto: { pageId, includeSubPages: false, publishedMode: 'approved' } as any,
|
||||
});
|
||||
|
||||
const rows = await manualRows(pageId);
|
||||
expect(rows).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('updateShare enabling approved on a live share mints the baseline; a second call is idempotent', async () => {
|
||||
const pageId = await newPage('later-saved body');
|
||||
const shareId = await insertShare({ pageId, publishedMode: 'live' });
|
||||
|
||||
expect(await manualRows(pageId)).toHaveLength(0);
|
||||
|
||||
await service.updateShare(shareId, {
|
||||
shareId,
|
||||
publishedMode: 'approved',
|
||||
} as any);
|
||||
expect(await manualRows(pageId)).toHaveLength(1);
|
||||
|
||||
// Idempotent: staying approved does not duplicate the baseline.
|
||||
await service.updateShare(shareId, {
|
||||
shareId,
|
||||
searchIndexing: true,
|
||||
} as any);
|
||||
expect(await manualRows(pageId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
// --- XOR gate ------------------------------------------------------------
|
||||
|
||||
it('createShare rejects approved + includeSubPages', async () => {
|
||||
const pageId = await newPage();
|
||||
await expect(
|
||||
service.createShare({
|
||||
authUserId: USER,
|
||||
workspaceId: wsId,
|
||||
page: { id: pageId, spaceId } as any,
|
||||
createShareDto: {
|
||||
pageId,
|
||||
includeSubPages: true,
|
||||
publishedMode: 'approved',
|
||||
} as any,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('updateShare rejects setting approved while includeSubPages is already on', async () => {
|
||||
const pageId = await newPage();
|
||||
const shareId = await insertShare({ pageId, includeSubPages: true });
|
||||
await expect(
|
||||
service.updateShare(shareId, { shareId, publishedMode: 'approved' } as any),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
// --- resolveReadableSharePage content freeze -----------------------------
|
||||
|
||||
it('resolveReadableSharePage under approved swaps content+title but PRESERVES id + workspaceId', async () => {
|
||||
const pageId = await newPage('LIVE draft body');
|
||||
const shareId = await insertShare({ pageId, publishedMode: 'approved' });
|
||||
|
||||
// Seed a manual version distinct from the live draft.
|
||||
await pageHistoryRepo.insertPageHistory({
|
||||
pageId,
|
||||
slugId: `hist-${pageId.slice(0, 8)}`,
|
||||
title: 'SAVED title',
|
||||
content: docWith('SAVED version body') as any,
|
||||
kind: 'manual',
|
||||
spaceId,
|
||||
workspaceId: wsId,
|
||||
} as any);
|
||||
|
||||
const resolved = await service.resolveReadableSharePage(
|
||||
shareId,
|
||||
pageId,
|
||||
wsId,
|
||||
);
|
||||
expect(resolved).not.toBeNull();
|
||||
// Content + title are the SAVED version.
|
||||
expect(resolved!.page.title).toBe('SAVED title');
|
||||
expect(JSON.stringify(resolved!.page.content)).toContain(
|
||||
'SAVED version body',
|
||||
);
|
||||
// id + workspaceId stay LIVE (attachment-token ownership must not shift).
|
||||
expect(resolved!.page.id).toBe(pageId);
|
||||
expect(resolved!.page.workspaceId).toBe(wsId);
|
||||
});
|
||||
});
|
||||
@@ -4,26 +4,11 @@ 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
|
||||
@@ -54,33 +39,7 @@ 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:
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
markdownToProseMirror,
|
||||
markdownToProseMirrorCanonical,
|
||||
mutatePageContent,
|
||||
savePageVersionRealtime,
|
||||
assertYjsEncodable,
|
||||
MutationResult,
|
||||
} from "../lib/collaboration.js";
|
||||
@@ -56,7 +55,6 @@ export interface IPagesMixin {
|
||||
listPageHistory(pageId: string, cursor?: string): any;
|
||||
getPageHistory(historyId: string): any;
|
||||
restorePageVersion(historyId: string): any;
|
||||
savePageVersion(pageId: string): any;
|
||||
diffPageVersions(pageId: string, from?: string, to?: string): any;
|
||||
}
|
||||
|
||||
@@ -589,24 +587,6 @@ export function PagesMixin<TBase extends GConstructor<DocmostClientContext>>(Bas
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an intentional NAMED version of a page's CURRENT live content (#370).
|
||||
* The write goes over the same agent-authenticated collab session content edits
|
||||
* use, so the server derives kind='agent' from the signed actor. Resolves a
|
||||
* SaveVersionResult: `{ saved:true, historyId, kind, alreadySaved }` on success,
|
||||
* or `{ saved:false, skipped:true, reason }` when the server had nothing to pin
|
||||
* (e.g. an empty page). A stale/missing pageId throws (not a benign skip).
|
||||
*/
|
||||
async savePageVersion(pageId: string) {
|
||||
const collabToken = await this.getCollabTokenWithReauth();
|
||||
const pageUuid = await this.resolvePageId(pageId);
|
||||
// Self-heal a rejected WS handshake once (#486): re-mint the collab token and
|
||||
// retry, symmetric to the content-write path.
|
||||
return this.writeWithCollabAuthRetry(collabToken, (token) =>
|
||||
savePageVersionRealtime(pageUuid, token, this.apiUrl),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Diff two versions of a page and return a Docmost-equivalent change set.
|
||||
* `from`/`to` each resolve to a ProseMirror doc:
|
||||
|
||||
@@ -139,13 +139,7 @@ export interface CollabProviderLike {
|
||||
unsyncedChanges: number;
|
||||
destroy(): void;
|
||||
on(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
|
||||
on(event: "stateless", handler: (data: { payload: string }) => void): void;
|
||||
off(event: "unsyncedChanges", handler: (data: { number: number }) => void): void;
|
||||
off(event: "stateless", handler: (data: { payload: string }) => void): void;
|
||||
// Send a stateless (out-of-band, non-doc) message to the server. Used by the
|
||||
// #370 explicit save-version path (payload `{type:'save-version'}`); the server
|
||||
// replies with a broadcast `{type:'version.saved', …}` on the same channel.
|
||||
sendStateless(payload: string): void;
|
||||
}
|
||||
|
||||
/** The configuration object passed to the provider factory. */
|
||||
@@ -570,125 +564,6 @@ export class CollabSession {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a stateless message over the live collaboration connection and resolve
|
||||
* with the FIRST reply whose parsed payload satisfies `predicate`, rejecting on
|
||||
* a bounded `timeoutMs`.
|
||||
*
|
||||
* Used by the #370 explicit save-version path: the client sends
|
||||
* `{type:'save-version'}` and awaits the server's broadcast
|
||||
* `{type:'version.saved', historyId, kind, alreadySaved}`. The stateless channel
|
||||
* (not a REST read) is deliberate — the server versions the LIVE in-memory ydoc,
|
||||
* which the up-to-10s-stale page row would not yet reflect.
|
||||
*
|
||||
* `predicate` receives each incoming stateless message (already JSON-parsed) and
|
||||
* returns the resolved value for a match or `undefined` to keep waiting; a
|
||||
* malformed / unrelated payload is ignored. The listener is registered BEFORE
|
||||
* the send so a fast reply is never missed.
|
||||
*
|
||||
* Lifecycle mirrors mutate(): it registers through `inflightReject` so a
|
||||
* disconnect/close/auth-failure/teardown rejects THIS wait with the same
|
||||
* connection-loss error, refuses to run on a non-ready session, fails fast on a
|
||||
* concurrent in-flight op (the caller MUST hold the per-page lock), and re-arms
|
||||
* the idle TTL (or self-destroys an ephemeral session) on completion.
|
||||
*
|
||||
* NOTE: the server broadcast carries no request-correlation id, so under a
|
||||
* genuinely concurrent save on the SAME page (e.g. a human Cmd+S racing the
|
||||
* agent) this resolves on whichever `version.saved` arrives first. The per-page
|
||||
* lock serializes THIS process's saves; a cross-client race is inherent to the
|
||||
* broadcast design and is benign (the result still describes a real save of this
|
||||
* page's current content, and the server's save is promote-not-duplicate).
|
||||
*/
|
||||
sendStatelessAndAwait<T>(
|
||||
payload: string,
|
||||
predicate: (message: any) => T | undefined,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
// Belt-and-suspenders (acquire already validated): refuse to send on a
|
||||
// session that is not in a live, synced, ready state.
|
||||
if (
|
||||
this.dead ||
|
||||
this.state !== "ready" ||
|
||||
this.connectionLost ||
|
||||
!this.provider ||
|
||||
this.provider.synced !== true
|
||||
) {
|
||||
return Promise.reject(
|
||||
new Error("Collaboration session is not in a ready state"),
|
||||
);
|
||||
}
|
||||
|
||||
// Fail-fast on concurrent use: a second overlapping op would overwrite the
|
||||
// first's inflightReject and hang it on disconnect (same guard as mutate).
|
||||
if (this.inflightReject) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
"stateless op already in-flight; caller must serialize (hold the page lock)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let statelessHandler: ((data: { payload: string }) => void) | undefined;
|
||||
|
||||
const localFinish = (err: Error | null, value?: T) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimeout(timer);
|
||||
if (statelessHandler && this.provider) {
|
||||
try {
|
||||
this.provider.off("stateless", statelessHandler);
|
||||
} catch (e) {}
|
||||
}
|
||||
this.inflightReject = undefined;
|
||||
if (err) reject(err);
|
||||
else resolve(value as T);
|
||||
// Post-settle lifecycle, identical to mutate's localFinish.
|
||||
if (this.ephemeral) {
|
||||
this.destroy("ephemeral op complete");
|
||||
} else if (!this.dead) {
|
||||
this.armIdle();
|
||||
}
|
||||
};
|
||||
|
||||
// Register so a disconnect/close/auth-failure/teardown rejects THIS op with
|
||||
// the connection-loss error text (the `settled` guard makes a racing
|
||||
// teardown + normal resolve safe — first one wins).
|
||||
this.inflightReject = (e: Error) => localFinish(e);
|
||||
|
||||
// Register the reply listener BEFORE sending so a fast server broadcast is
|
||||
// never missed.
|
||||
statelessHandler = (data: { payload: string }) => {
|
||||
if (settled) return;
|
||||
let message: any;
|
||||
try {
|
||||
message = JSON.parse(data.payload);
|
||||
} catch {
|
||||
return; // unrelated / malformed stateless message — keep waiting
|
||||
}
|
||||
const matched = predicate(message);
|
||||
if (matched !== undefined) localFinish(null, matched);
|
||||
};
|
||||
this.provider!.on("stateless", statelessHandler);
|
||||
|
||||
timer = setTimeout(() => {
|
||||
localFinish(
|
||||
new Error(
|
||||
`Timeout waiting for a stateless reply from the collaboration server ${this.hint()}`,
|
||||
),
|
||||
);
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
this.provider!.sendStateless(payload);
|
||||
} catch (e) {
|
||||
localFinish(e instanceof Error ? e : new Error(String(e)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** (Re)arm the idle TTL so the clock starts from the most recent activity. */
|
||||
armIdle(): void {
|
||||
if (this.dead || this.ephemeral) return;
|
||||
|
||||
@@ -324,146 +324,6 @@ export async function mutatePageContent(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stateless-channel message types for the #370 explicit save-version handshake.
|
||||
* These MUST match the server constants in
|
||||
* apps/server/src/collaboration/extensions/persistence.extension.ts
|
||||
* (SAVE_VERSION_MESSAGE_TYPE and the VERSION_SAVED / VERSION_SKIPPED replies) —
|
||||
* the mcp package cannot import server code, so the literals are duplicated here.
|
||||
* KEEP THE TWO SIDES IN SYNC: the server broadcasts exactly ONE terminal reply per
|
||||
* handled save (`version.saved` for a real save/promote, `version.skipped` for a
|
||||
* reachable no-op — an empty page or a missing page row); the client must match
|
||||
* BOTH or it waits out the ack timeout on the skip case and misreports a healthy
|
||||
* server as unreachable (#370 F2).
|
||||
*/
|
||||
const SAVE_VERSION_MESSAGE_TYPE = "save-version";
|
||||
const VERSION_SAVED_MESSAGE_TYPE = "version.saved";
|
||||
const VERSION_SKIPPED_MESSAGE_TYPE = "version.skipped";
|
||||
|
||||
/**
|
||||
* Bounded wait for the server's terminal reply after we ask it to save a version.
|
||||
* The server flushes the live ydoc through its store path and writes a history row
|
||||
* inside a DB transaction before broadcasting, so allow the same headroom as a
|
||||
* persistence ack; reject (do NOT hang) past it. A timeout here means NO reply at
|
||||
* all arrived — the genuine "collab server unreachable/overloaded" case — as
|
||||
* distinct from a `version.skipped` reply, which resolves immediately.
|
||||
*/
|
||||
const SAVE_VERSION_ACK_TIMEOUT_MS = 20000;
|
||||
|
||||
/**
|
||||
* The resolved outcome of an explicit save-version, surfaced to the tool caller.
|
||||
* `saved:true` → a version was created or promoted (historyId/kind/alreadySaved
|
||||
* are present). `saved:false` → the server had nothing to pin (a reachable no-op,
|
||||
* e.g. an empty page); `skipped` + `reason` explain why. A page-not-found reply is
|
||||
* NOT represented here — it is surfaced as a thrown error (a bad/stale pageId).
|
||||
*/
|
||||
export interface SaveVersionResult {
|
||||
saved: boolean;
|
||||
historyId?: string;
|
||||
kind?: string;
|
||||
alreadySaved?: boolean;
|
||||
skipped?: boolean;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed terminal reply the predicate hands back to savePageVersionRealtime. Kept
|
||||
* internal: it carries the page-not-found case (mapped to a thrown error, never a
|
||||
* returned result) that SaveVersionResult deliberately does not model.
|
||||
*/
|
||||
type SaveVersionReply =
|
||||
| { outcome: "saved"; historyId: string; kind: string; alreadySaved: boolean }
|
||||
| { outcome: "skipped"; reason: string };
|
||||
|
||||
/** Match the server's terminal reply (version.saved / version.skipped), ignoring
|
||||
* any unrelated / cross-page stateless message so the wait is not resolved by
|
||||
* noise. Returns `undefined` to keep waiting (#370 F1 predicate coverage). */
|
||||
function matchSaveVersionReply(message: any): SaveVersionReply | undefined {
|
||||
if (!message || typeof message !== "object") return undefined;
|
||||
if (message.type === VERSION_SAVED_MESSAGE_TYPE) {
|
||||
return {
|
||||
outcome: "saved",
|
||||
historyId: String(message.historyId),
|
||||
kind: String(message.kind),
|
||||
alreadySaved: !!message.alreadySaved,
|
||||
};
|
||||
}
|
||||
if (message.type === VERSION_SKIPPED_MESSAGE_TYPE) {
|
||||
return { outcome: "skipped", reason: String(message.reason ?? "unknown") };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save an intentional version of a page's CURRENT live collaboration content
|
||||
* (#370). Runs under the per-page lock, acquires the SAME cached CollabSession the
|
||||
* content writes use (#400) — so it authenticates with the caller's agent collab
|
||||
* token, and the server derives kind='agent' from that signed actor — then sends a
|
||||
* `{type:'save-version'}` stateless message and awaits the server's terminal reply
|
||||
* (`version.saved` or `version.skipped`) on the same channel.
|
||||
*
|
||||
* Deliberately does NOT read `pages.content` over REST: the versioned content is
|
||||
* the live in-memory ydoc, which the debounced (up-to-10s-stale) page row would
|
||||
* not yet reflect. The stateless round-trip is what makes the save exact.
|
||||
*
|
||||
* Outcomes:
|
||||
* - a real save/promote → `{ saved:true, historyId, kind, alreadySaved }`;
|
||||
* - the server had nothing to pin (empty page) → `{ saved:false, skipped:true,
|
||||
* reason:'empty' }` — a clean, immediate no-op, NOT a stall;
|
||||
* - the page row is gone (a stale/bad pageId) → a thrown error, immediate and
|
||||
* truthful (not the health-timeout path);
|
||||
* - no reply within the timeout → a thrown timeout error (the genuine "server
|
||||
* unreachable/overloaded" case).
|
||||
*
|
||||
* On a TRANSPORT failure (timeout / disconnect) the session is destroyed so the
|
||||
* next call reconnects fresh; a page-not-found is a healthy-connection terminal
|
||||
* reply, so the session is left cached. A save is safe to retry — the server
|
||||
* promotes-not-duplicates an identical latest version — so the caller (and its
|
||||
* agent) may re-issue it without risking a duplicate heavy history row.
|
||||
*/
|
||||
export async function savePageVersionRealtime(
|
||||
pageId: PageId,
|
||||
collabToken: string,
|
||||
baseUrl: string,
|
||||
): Promise<SaveVersionResult> {
|
||||
return withPageLock(pageId, async () => {
|
||||
const session = await acquireCollabSession(pageId, collabToken, baseUrl);
|
||||
let reply: SaveVersionReply;
|
||||
try {
|
||||
reply = await session.sendStatelessAndAwait<SaveVersionReply>(
|
||||
JSON.stringify({ type: SAVE_VERSION_MESSAGE_TYPE }),
|
||||
matchSaveVersionReply,
|
||||
SAVE_VERSION_ACK_TIMEOUT_MS,
|
||||
);
|
||||
} catch (e) {
|
||||
// TRANSPORT failure (no reply within the timeout, or a disconnect): drop the
|
||||
// session so the next call reconnects fresh.
|
||||
session.destroy("save-version failed");
|
||||
throw e;
|
||||
}
|
||||
// A terminal reply arrived over a healthy connection — do NOT destroy the
|
||||
// session; interpret the outcome.
|
||||
if (reply.outcome === "skipped") {
|
||||
if (reply.reason === "page-not-found") {
|
||||
// A resolved pageId that the collab server no longer holds (deleted, or a
|
||||
// stale id). Surface it immediately and truthfully, not as a health error.
|
||||
throw new Error(
|
||||
`savePageVersion: page ${pageId} was not found on the collaboration ` +
|
||||
`server (it may have been deleted) — nothing was saved.`,
|
||||
);
|
||||
}
|
||||
// Reachable benign no-op (e.g. an empty page): a clean result, not a throw.
|
||||
return { saved: false, skipped: true, reason: reply.reason };
|
||||
}
|
||||
return {
|
||||
saved: true,
|
||||
historyId: reply.historyId,
|
||||
kind: reply.kind,
|
||||
alreadySaved: reply.alreadySaved,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the live content of a page over the collaboration websocket.
|
||||
* Accepts a ready ProseMirror JSON document; the caller controls whether
|
||||
|
||||
@@ -44,7 +44,7 @@ export const ROUTING_PROSE =
|
||||
"EDIT: fix wording/typos/numbers -> editPageText (find/replace inside blocks, no node id needed). Edit a block -> getNode(markdown) -> edit the markdown -> patchNode(markdown) (by attrs.id from getOutline; the markdown fragment may be several blocks — a 1->N section rewrite in one call, the first block keeps the id). Reach for patchNode's `node`-JSON only for fine attr/mark work; a table cell with spans/colors/fixed width -> the table tools (patchNode markdown refuses it). Add a block -> insertNode (markdown, before/after a block by attrs.id or by anchor text, or append; `node` for raw JSON or bare table structure). Remove a block -> deleteNode (by attrs.id). Tables -> tableGet / tableUpdateCell / tableInsertRow / tableDeleteRow (address by \"#<index>\" from getOutline; table nodes have no attrs.id). Images -> insertImage (add from a web URL) / replaceImage (swap an existing image). Draw.io diagrams -> PREFER the high-level semantic tools that hide coordinates/styles: drawioFromGraph (architecture/cloud/network diagrams — describe nodes/groups/edges by kind+icon, the server picks layout, colors and verified icons; hints layer/sameLayerAs/pinned and layout:full|incremental|none) and drawioFromMermaid (standard flowcharts — write Mermaid, get an editable diagram). For targeted tweaks of an existing diagram use drawioEditCells (id-based add/update/delete with cascade delete + baseHash lock). Raw mxGraph XML via drawioCreate/drawioUpdate is the escape-hatch for exotic/wireframe diagrams; drawioGet reads a diagram as mxGraph XML + a hash (pass it as baseHash to drawioUpdate/drawioEditCells for optimistic locking). Before authoring raw XML, drawioShapes (look up verified stencil style-strings so a shape name never renders as an empty box) and drawioGuide (on-demand authoring reference: skeleton/layout/containers/icons-aws/icons-azure), and pass layout:\"elk\" to drawioCreate/drawioUpdate to auto-place nodes. Footnotes -> insertFootnote. Bulk/structural rewrite -> updatePageJson (full ProseMirror replace) or updatePageMarkdown (full plain-Markdown body replace, re-imported — block ids regenerate); prefer the granular tools above to avoid resending the whole ~100KB+ document. Complex/scripted rewrite (multiple coordinated edits, renumbering) -> docmostTransform: write a JS `(doc, ctx) => doc` transform, preview the diff with dryRun (default), then apply with dryRun:false; ctx.helpers includes commentsToFootnotes for turning inline comments into numbered footnotes.\n" +
|
||||
"PAGES: new -> createPage (Markdown). Rename (title only) -> renamePage. Move -> movePage. Delete -> deletePage (SOFT delete — the page goes to trash and is restorable; nothing is permanent). Copy/replace a page's whole content from another page (server-side, no document through the model) -> copyPageContent. Sharing -> sharePage / unsharePage / listShares; sharePage makes the page PUBLICLY accessible — do it only when explicitly asked.\n" +
|
||||
"COMMENTS: createComment is always inline and requires an EXACT selection — contiguous text from a single block, <=250 chars (fails rather than leaving an unanchored comment); reply to a thread via parentCommentId. Propose a concrete text fix for one-click human approval -> createComment with suggestedText (the exact plain-text replacement for the selection; the selection must then be UNIQUE in the page — extend it with context if needed); prefer this over editing directly when the change is subjective or needs the author's sign-off. Manage -> listComments, updateComment, resolveComment (resolve/reopen, reversible — prefer over delete to close), deleteComment, checkNewComments.\n" +
|
||||
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Pin the page's CURRENT content as a restorable named checkpoint when you finish a coherent editing pass -> savePageVersion (kind derived server-side as an agent version; an identical-to-last save is promoted/no-op'd, so a redundant call is harmless). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||
"HISTORY: review what changed -> diffPageVersions (a historyId vs current, or two versions). List saved versions -> listPageHistory. Undo a bad edit -> restorePageVersion (writes a past version back as current; itself revertible). Export a page to self-contained Docmost Markdown (with comment anchors) -> exportPageMarkdown.";
|
||||
|
||||
/**
|
||||
* Non-tool camelCase identifiers that legitimately appear in ROUTING_PROSE:
|
||||
@@ -218,7 +218,6 @@ const TOOL_FAMILY: Record<string, Family> = {
|
||||
diffPageVersions: "HISTORY",
|
||||
listPageHistory: "HISTORY",
|
||||
restorePageVersion: "HISTORY",
|
||||
savePageVersion: "HISTORY",
|
||||
exportPageMarkdown: "HISTORY",
|
||||
// importPageMarkdown is now inAppOnly (#411) — it is not registered on the
|
||||
// external MCP host, so it no longer appears in the generated inventory.
|
||||
|
||||
@@ -93,7 +93,6 @@ export type DocmostClientLike = Pick<
|
||||
| 'sharePage'
|
||||
| 'unsharePage'
|
||||
| 'restorePageVersion'
|
||||
| 'savePageVersion'
|
||||
| 'stashPage'
|
||||
| 'insertFootnote'
|
||||
| 'insertImage'
|
||||
@@ -744,30 +743,6 @@ export const SHARED_TOOL_SPECS = {
|
||||
client.restorePageVersion(historyId as string),
|
||||
},
|
||||
|
||||
savePageVersion: {
|
||||
mcpName: 'savePageVersion',
|
||||
inAppKey: 'savePageVersion',
|
||||
writeClass: 'write',
|
||||
description:
|
||||
'Save an intentional, NAMED version (kind=agent) of the page\'s CURRENT ' +
|
||||
'live content — a restorable checkpoint pinned into its history. Call it ' +
|
||||
'when you have FINISHED a coherent editing pass (not after every small ' +
|
||||
'edit), so the reader can see and roll back to the state you left. The ' +
|
||||
'version type is derived SERVER-SIDE from your signed agent identity (you ' +
|
||||
'cannot mislabel it); a save whose content is IDENTICAL to the last saved ' +
|
||||
'version is promoted/no-op\'d server-side, so a redundant call is harmless ' +
|
||||
'and never duplicates a version. Returns { saved:true, historyId, kind, ' +
|
||||
'alreadySaved } on success, or { saved:false, skipped:true, reason:\'empty\' } ' +
|
||||
'when the page had nothing to pin (e.g. it was empty).',
|
||||
tier: 'deferred',
|
||||
catalogLine:
|
||||
'savePageVersion — pin the page\'s current content as a named agent version (restorable checkpoint).',
|
||||
buildShape: (z) => ({
|
||||
pageId: z.string().min(1),
|
||||
}),
|
||||
execute: (client, { pageId }) => client.savePageVersion(pageId as string),
|
||||
},
|
||||
|
||||
// --- markdown round-trip ---
|
||||
|
||||
importPageMarkdown: {
|
||||
|
||||
@@ -1,300 +0,0 @@
|
||||
import { test, beforeEach, afterEach, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
import {
|
||||
acquireCollabSession,
|
||||
destroyAllSessions,
|
||||
__setCollabProviderFactory,
|
||||
} from "../../build/lib/collab-session.js";
|
||||
import { savePageVersionRealtime } from "../../build/lib/collaboration.js";
|
||||
|
||||
// #370 — unit coverage for the MCP client transport of the explicit save-version.
|
||||
// There is no collaboration server in the test env, so a fake HocuspocusProvider
|
||||
// drives the stateless round-trip: it auto-completes the connect/sync handshake on
|
||||
// a microtask (like the real provider), records sends, and either auto-broadcasts a
|
||||
// configured reply (`opts.reply`) or lets the test emit stateless messages /
|
||||
// disconnects by hand (`_emitStateless` / `_disconnect`) to exercise the predicate
|
||||
// filter, the teardown-mid-wait path, and the timeout.
|
||||
class FakeProvider extends EventEmitter {
|
||||
static instances = [];
|
||||
|
||||
static reset() {
|
||||
FakeProvider.instances = [];
|
||||
}
|
||||
|
||||
static last() {
|
||||
return FakeProvider.instances[FakeProvider.instances.length - 1];
|
||||
}
|
||||
|
||||
constructor(config, opts = {}) {
|
||||
super();
|
||||
this.config = config;
|
||||
this.synced = false;
|
||||
this.unsyncedChanges = 0;
|
||||
this.destroyed = false;
|
||||
this.sent = [];
|
||||
this.opts = opts;
|
||||
FakeProvider.instances.push(this);
|
||||
// Real HocuspocusProvider fires onSynced asynchronously after the handshake;
|
||||
// a microtask reproduces that without depending on timers (so mock.timers on
|
||||
// setTimeout does not stall the connect).
|
||||
queueMicrotask(() => {
|
||||
if (this.destroyed) return;
|
||||
this.config.onConnect?.();
|
||||
this.synced = true;
|
||||
this.config.onSynced?.();
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.destroyed = true;
|
||||
}
|
||||
|
||||
sendStateless(payload) {
|
||||
this.sent.push(payload);
|
||||
const reply = this.opts.reply;
|
||||
if (!reply) return; // silent → the caller times out, or the test drives it
|
||||
// Broadcast the configured server reply on a microtask, mirroring
|
||||
// document.broadcastStateless landing back on this provider's `stateless` event.
|
||||
queueMicrotask(() => {
|
||||
if (this.destroyed) return;
|
||||
this.emit("stateless", { payload: JSON.stringify(reply) });
|
||||
});
|
||||
}
|
||||
|
||||
// --- test drivers ---
|
||||
_emitStateless(obj) {
|
||||
this.emit("stateless", { payload: JSON.stringify(obj) });
|
||||
}
|
||||
_disconnect() {
|
||||
this.config.onDisconnect?.();
|
||||
}
|
||||
}
|
||||
|
||||
const ENV_KEYS = [
|
||||
"MCP_COLLAB_SESSION_IDLE_MS",
|
||||
"MCP_COLLAB_SESSION_MAX_AGE_MS",
|
||||
"MCP_COLLAB_SESSION_MAX_ENTRIES",
|
||||
"MCP_COLLAB_TOKEN_TTL_MS",
|
||||
];
|
||||
let savedEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
savedEnv = {};
|
||||
for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
|
||||
FakeProvider.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
destroyAllSessions();
|
||||
__setCollabProviderFactory(null);
|
||||
mock.timers.reset();
|
||||
for (const k of ENV_KEYS) {
|
||||
if (savedEnv[k] === undefined) delete process.env[k];
|
||||
else process.env[k] = savedEnv[k];
|
||||
}
|
||||
});
|
||||
|
||||
const PAGE_A = "11111111-1111-4111-8111-111111111111";
|
||||
const PAGE_B = "22222222-2222-4222-8222-222222222222";
|
||||
const PAGE_C = "33333333-3333-4333-8333-333333333333";
|
||||
const PAGE_D = "44444444-4444-4444-8444-444444444444";
|
||||
const PAGE_E = "55555555-5555-4555-8555-555555555555";
|
||||
const PAGE_F = "66666666-6666-4666-8666-666666666666";
|
||||
|
||||
// --- happy paths (resolve with the server's terminal reply) -----------------
|
||||
|
||||
test("saved: resolves { saved:true, historyId, kind, alreadySaved }", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: {
|
||||
type: "version.saved",
|
||||
historyId: "hist-42",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await savePageVersionRealtime(PAGE_A, "collab-tok", "http://host/api");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
saved: true,
|
||||
historyId: "hist-42",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
});
|
||||
// The client actually asked the server to save a version (the stateless send).
|
||||
assert.deepEqual(
|
||||
FakeProvider.last().sent.map((p) => JSON.parse(p)),
|
||||
[{ type: "save-version" }],
|
||||
);
|
||||
});
|
||||
|
||||
test("saved: surfaces alreadySaved=true (promote-not-dup / no-op save)", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: {
|
||||
type: "version.saved",
|
||||
historyId: "hist-7",
|
||||
kind: "manual",
|
||||
alreadySaved: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
|
||||
|
||||
assert.deepEqual(result, {
|
||||
saved: true,
|
||||
historyId: "hist-7",
|
||||
kind: "manual",
|
||||
alreadySaved: true,
|
||||
});
|
||||
});
|
||||
|
||||
// --- F2: terminal SKIP replies (no timeout, no false "unreachable") ---------
|
||||
|
||||
test("F2 skip: an empty-page reply resolves to a clean { saved:false, skipped:true, reason:'empty' } — no timeout", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: { type: "version.skipped", reason: "empty" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await savePageVersionRealtime(PAGE_C, "collab-tok", "http://host/api");
|
||||
|
||||
assert.deepEqual(result, { saved: false, skipped: true, reason: "empty" });
|
||||
});
|
||||
|
||||
test("F2 skip: a page-not-found reply throws an immediate, truthful error (not the health-timeout path)", async () => {
|
||||
__setCollabProviderFactory(
|
||||
(config) =>
|
||||
new FakeProvider(config, {
|
||||
reply: { type: "version.skipped", reason: "page-not-found" },
|
||||
}),
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
savePageVersionRealtime(PAGE_D, "collab-tok", "http://host/api"),
|
||||
/was not found on the collaboration server/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- F1 (a): predicate filters unrelated messages, resolves on the real reply ---
|
||||
|
||||
test("F1(a) unrelated-then-real: an unrelated stateless message does NOT resolve; the later version.saved does", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // manual drive
|
||||
|
||||
const p = savePageVersionRealtime(PAGE_E, "collab-tok", "http://host/api");
|
||||
// Let acquire's handshake + the stateless send complete so the listener is armed.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
const provider = FakeProvider.last();
|
||||
|
||||
// Noise first: an unrelated stateless type (e.g. another feature's broadcast).
|
||||
provider._emitStateless({ type: "something-else", historyId: "NOPE" });
|
||||
// The wait must still be pending — a microtask turn would have resolved it if the
|
||||
// predicate wrongly matched.
|
||||
let settledEarly = false;
|
||||
p.then(() => (settledEarly = true)).catch(() => (settledEarly = true));
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(settledEarly, false, "resolved on an unrelated stateless message");
|
||||
|
||||
// Now the real reply arrives → it resolves with that payload.
|
||||
provider._emitStateless({
|
||||
type: "version.saved",
|
||||
historyId: "h-real",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
});
|
||||
const result = await p;
|
||||
assert.deepEqual(result, {
|
||||
saved: true,
|
||||
historyId: "h-real",
|
||||
kind: "agent",
|
||||
alreadySaved: false,
|
||||
});
|
||||
});
|
||||
|
||||
// --- F1 (b): teardown mid-wait rejects AND removes the stateless listener -----
|
||||
|
||||
test("F1(b) teardown mid-wait: a disconnect rejects the wait with the connection-loss error AND removes the stateless listener (no leak)", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
|
||||
|
||||
const session = await acquireCollabSession(PAGE_F, "collab-tok", "http://host/api");
|
||||
const provider = FakeProvider.last();
|
||||
|
||||
const p = session.sendStatelessAndAwait(
|
||||
JSON.stringify({ type: "save-version" }),
|
||||
(m) => (m && m.type === "version.saved" ? m : undefined),
|
||||
20000,
|
||||
);
|
||||
// Swallow the eventual rejection so it does not race the assertions below.
|
||||
p.catch(() => {});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// The wait armed exactly one stateless listener.
|
||||
assert.equal(
|
||||
provider.listenerCount("stateless"),
|
||||
1,
|
||||
"the wait must register one stateless listener",
|
||||
);
|
||||
|
||||
// A disconnect tears the session down mid-wait.
|
||||
provider._disconnect();
|
||||
|
||||
await assert.rejects(p, /connection closed before the update was persisted/);
|
||||
// The listener was removed on teardown — dropping provider.off() reds this.
|
||||
assert.equal(
|
||||
provider.listenerCount("stateless"),
|
||||
0,
|
||||
"the stateless listener leaked after teardown",
|
||||
);
|
||||
});
|
||||
|
||||
// --- F1 (c): concurrent-in-flight guard fails fast, no cross-wiring -----------
|
||||
|
||||
test("F1(c) concurrent guard: a second sendStatelessAndAwait while one is in flight fails fast", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
|
||||
|
||||
const session = await acquireCollabSession(PAGE_A, "collab-tok", "http://host/api");
|
||||
|
||||
const first = session.sendStatelessAndAwait(
|
||||
JSON.stringify({ type: "save-version" }),
|
||||
(m) => (m && m.type === "version.saved" ? m : undefined),
|
||||
20000,
|
||||
);
|
||||
first.catch(() => {}); // it stays pending until afterEach destroys the session
|
||||
|
||||
await assert.rejects(
|
||||
session.sendStatelessAndAwait(
|
||||
JSON.stringify({ type: "save-version" }),
|
||||
(m) => (m && m.type === "version.saved" ? m : undefined),
|
||||
20000,
|
||||
),
|
||||
/already in-flight/,
|
||||
);
|
||||
});
|
||||
|
||||
// --- timeout: NO reply at all is the genuine "server unreachable" path --------
|
||||
|
||||
test("timeout: rejects on a bounded timeout when the server never replies", async () => {
|
||||
__setCollabProviderFactory((config) => new FakeProvider(config, {})); // silent
|
||||
|
||||
mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const p = savePageVersionRealtime(PAGE_B, "collab-tok", "http://host/api");
|
||||
// Swallow the eventual rejection so an unhandled-rejection does not race the tick.
|
||||
p.catch(() => {});
|
||||
|
||||
// Flush the (microtask-driven) connect handshake + the stateless send so the
|
||||
// ack timer is armed before we advance the clock.
|
||||
await new Promise((r) => setImmediate(r));
|
||||
// Advance well past the 20s ack window; the connect timer (25s) does not fire.
|
||||
mock.timers.tick(20000 + 100);
|
||||
|
||||
await assert.rejects(p, /Timeout waiting for a stateless reply/);
|
||||
});
|
||||
Reference in New Issue
Block a user