{children}
+ ), + ul: ({ children }: { children?: React.ReactNode }) => ( +
+ {children}
+
+ ),
+};
+
+const WikiViewer: React.FC{parts.join(' · ')}
+ ); + }; + + return ( ++ + {t('settings.sources.wiki.livingTitle')} + {' '} + {t('settings.sources.wiki.livingExplainer')} + {canEdit && ( + <> {t('settings.sources.wiki.livingExplainerEditable')}> + )} +
++ {t('settings.sources.wiki.empty')} +
+ ) : ( ++ {editError} +
+ )} + + {isEditing ? ( ++ {t('settings.sources.wiki.selectPage')} +
+ )} +
+ {path}
+
+ )}
+ {isError && (
+
+ {t('conversation.wikiWrite.failed')}
+
+ )}
+ {
+ const renderToolCalls = (toolCalls: ToolCallsType[]): string =>
+ renderToStaticMarkup(
+
+
+ ,
+ );
+
+ const wikiWrite: ToolCallsType = {
+ tool_name: 'wiki',
+ action_name: 'wiki_str_replace',
+ call_id: 'w1',
+ arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
+ status: 'completed',
+ };
+
+ const otherCall: ToolCallsType = {
+ tool_name: 'memory',
+ action_name: 'memory_search',
+ call_id: 'm1',
+ arguments: { query: 'secret-arg' },
+ status: 'completed',
+ };
+
+ it('renders wiki write cards outside the collapsed accordion (default closed)', () => {
+ const html = renderToolCalls([wikiWrite, otherCall]);
+ expect(html).toContain('Edited wiki page');
+ expect(html).toContain('/policy.md');
+ expect(html).not.toContain('secret-arg');
+ });
+
+ it('does not double-render wiki write cards inside the accordion', () => {
+ const html = renderToolCalls([wikiWrite]);
+ const occurrences = html.split('Edited wiki page').length - 1;
+ expect(occurrences).toBe(1);
+ });
+});
diff --git a/frontend/src/conversation/wikiToolCall.test.ts b/frontend/src/conversation/wikiToolCall.test.ts
new file mode 100644
index 00000000..31773368
--- /dev/null
+++ b/frontend/src/conversation/wikiToolCall.test.ts
@@ -0,0 +1,69 @@
+import { describe, expect, it } from 'vitest';
+
+import { ToolCallsType } from './types';
+import {
+ isWikiWriteCall,
+ wikiWriteActionKey,
+ wikiWritePath,
+} from './wikiToolCall';
+
+const call = (overrides: Partial): ToolCallsType => ({
+ tool_name: 'wiki',
+ action_name: 'wiki_str_replace',
+ call_id: 'c1',
+ arguments: {},
+ ...overrides,
+});
+
+describe('isWikiWriteCall', () => {
+ it('flags wiki write actions', () => {
+ expect(isWikiWriteCall(call({ action_name: 'wiki_create' }))).toBe(true);
+ expect(isWikiWriteCall(call({ action_name: 'wiki_str_replace' }))).toBe(
+ true,
+ );
+ expect(isWikiWriteCall(call({ action_name: 'wiki_insert' }))).toBe(true);
+ expect(isWikiWriteCall(call({ action_name: 'wiki_delete' }))).toBe(true);
+ expect(isWikiWriteCall(call({ action_name: 'wiki_rename' }))).toBe(true);
+ });
+
+ it('does not flag wiki read actions', () => {
+ expect(isWikiWriteCall(call({ action_name: 'wiki_view' }))).toBe(false);
+ });
+
+ it('does not flag non-wiki tools', () => {
+ expect(
+ isWikiWriteCall(
+ call({ tool_name: 'memory', action_name: 'wiki_create' }),
+ ),
+ ).toBe(false);
+ });
+});
+
+describe('wikiWritePath', () => {
+ it('reads the path argument for str_replace', () => {
+ const tc = call({
+ action_name: 'wiki_str_replace',
+ arguments: { path: '/policy.md', old_str: 'a', new_str: 'b' },
+ });
+ expect(wikiWritePath(tc)).toBe('/policy.md');
+ });
+
+ it('prefers new_path for a rename', () => {
+ const tc = call({
+ action_name: 'wiki_rename',
+ arguments: { old_path: '/a.md', new_path: '/b.md' },
+ });
+ expect(wikiWritePath(tc)).toBe('/b.md');
+ });
+
+ it('returns null when no path is present in the payload', () => {
+ expect(wikiWritePath(call({ arguments: {} }))).toBeNull();
+ });
+});
+
+describe('wikiWriteActionKey', () => {
+ it('strips the wiki_ prefix', () => {
+ expect(wikiWriteActionKey('wiki_str_replace')).toBe('str_replace');
+ expect(wikiWriteActionKey('wiki_create')).toBe('create');
+ });
+});
diff --git a/frontend/src/conversation/wikiToolCall.ts b/frontend/src/conversation/wikiToolCall.ts
new file mode 100644
index 00000000..a6404da7
--- /dev/null
+++ b/frontend/src/conversation/wikiToolCall.ts
@@ -0,0 +1,26 @@
+import { ToolCallsType } from './types';
+
+const WIKI_WRITE_ACTIONS = new Set([
+ 'wiki_create',
+ 'wiki_str_replace',
+ 'wiki_insert',
+ 'wiki_delete',
+ 'wiki_rename',
+]);
+
+export function isWikiWriteCall(toolCall: ToolCallsType): boolean {
+ return (
+ toolCall.tool_name === 'wiki' &&
+ WIKI_WRITE_ACTIONS.has(toolCall.action_name)
+ );
+}
+
+export function wikiWritePath(toolCall: ToolCallsType): string | null {
+ const args = toolCall.arguments || {};
+ const path = args.new_path ?? args.path;
+ return typeof path === 'string' && path.length > 0 ? path : null;
+}
+
+export function wikiWriteActionKey(actionName: string): string {
+ return actionName.replace(/^wiki_/, '');
+}
diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json
index 4f5330e7..bae7bbd8 100644
--- a/frontend/src/locale/en.json
+++ b/frontend/src/locale/en.json
@@ -103,6 +103,52 @@
},
"actions": "Actions",
"view": "View",
+ "wiki": {
+ "view": "View wiki",
+ "empty": "This wiki has no pages yet.",
+ "selectPage": "Select a page to view its contents.",
+ "edit": "Edit",
+ "save": "Save",
+ "cancel": "Cancel",
+ "saving": "Saving…",
+ "saveFailed": "Could not save the page. Please try again.",
+ "forbidden": "You do not have permission to edit this page.",
+ "conflict": "This page changed since you opened it and has been reloaded. Your edits are below — reapply and save again.",
+ "editPlaceholder": "Write the page in Markdown…",
+ "livingTitle": "Living wiki.",
+ "livingExplainer": "An agent-editable source: pages are read and rewritten as agents work, and it is exposed to agents through the agentic search tool by default.",
+ "livingExplainerEditable": "Any agent using this source can edit it.",
+ "stamp": {
+ "editedBy": "Edited by {{who}}",
+ "agent": "agent",
+ "human": "human",
+ "you": "you",
+ "unknown": "unknown",
+ "version": "v{{version}}"
+ },
+ "convert": {
+ "action": "Convert to wiki",
+ "title": "Convert to a living wiki",
+ "intro": "Convert \"{{name}}\" into a living wiki of editable pages that agents can read and rewrite.",
+ "introGeneric": "Convert this source into a living wiki of editable pages that agents can read and rewrite.",
+ "costReparse": "Files are re-parsed into editable pages. Formatting is simplified, especially for PDFs.",
+ "costSkipped": "Non-text files (images, binaries) are skipped.",
+ "costIrreversible": "This is not reversible — the wiki pages become the source of truth.",
+ "confirm": "Convert to wiki",
+ "inProgress": "Converting files into wiki pages…",
+ "summaryPages_one": "{{count}} page created",
+ "summaryPages_other": "{{count}} pages created",
+ "summarySkipped_one": "{{count}} file skipped",
+ "summarySkipped_other": "{{count}} files skipped",
+ "skippedHeading": "Skipped files",
+ "done": "Done",
+ "errors": {
+ "generic": "Conversion failed. Please try again.",
+ "forbidden": "You don't have permission to convert this source.",
+ "conflict": "This source is still ingesting. Wait for it to finish before converting."
+ }
+ }
+ },
"editConfig": "Source settings",
"shareWithTeam": "Share with team",
"deleteWarning": "Are you sure you want to delete \"{{name}}\"?",
@@ -638,6 +684,7 @@
"uploadedFiles": "Uploaded Files",
"cancel": "Cancel",
"train": "Train",
+ "create": "Create",
"link": "Link",
"urlLink": "URL Link",
"repoUrl": "Repository URL",
@@ -707,6 +754,10 @@
"confluence": {
"label": "Confluence",
"heading": "Upload from Confluence"
+ },
+ "wiki": {
+ "label": "New wiki",
+ "heading": "Create a living wiki"
}
},
"connectors": {
@@ -953,7 +1004,16 @@
"remove": "Remove attachment"
},
"retry": "Retry",
- "reasoning": "Reasoning"
+ "reasoning": "Reasoning",
+ "wikiWrite": {
+ "edited": "Edited wiki",
+ "create": "Created wiki page",
+ "str_replace": "Edited wiki page",
+ "insert": "Edited wiki page",
+ "delete": "Deleted wiki page",
+ "rename": "Renamed wiki page",
+ "failed": "failed"
+ }
},
"agents": {
"title": "Agents",
diff --git a/frontend/src/settings/ConvertToWikiModal.tsx b/frontend/src/settings/ConvertToWikiModal.tsx
new file mode 100644
index 00000000..d024b8b2
--- /dev/null
+++ b/frontend/src/settings/ConvertToWikiModal.tsx
@@ -0,0 +1,245 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { useSelector } from 'react-redux';
+
+import userService from '../api/services/userService';
+import Spinner from '../components/Spinner';
+import { Button } from '../components/ui/button';
+import { Modal } from '../components/ui/modal';
+import { ActiveState, Doc } from '../models/misc';
+import { selectToken } from '../preferences/preferenceSlice';
+
+import {
+ ConvertSummary,
+ pollTaskOnce,
+ startWikiConversion,
+} from './wikiConvertUtils';
+
+const POLL_INTERVAL_MS = 2000;
+
+type Phase = 'confirm' | 'converting' | 'summary' | 'error';
+
+interface ConvertToWikiModalProps {
+ modalState: ActiveState;
+ setModalState: (state: ActiveState) => void;
+ document: Doc | null;
+ onConverted: () => void;
+}
+
+export default function ConvertToWikiModal({
+ modalState,
+ setModalState,
+ document,
+ onConverted,
+}: ConvertToWikiModalProps) {
+ const { t } = useTranslation();
+ const token = useSelector(selectToken);
+
+ const [phase, setPhase] = useState('confirm');
+ const [error, setError] = useState(null);
+ const [summary, setSummary] = useState(null);
+ const pollTimer = useRef(null);
+ const isActiveRef = useRef(true);
+
+ const clearPoll = () => {
+ if (pollTimer.current !== null) {
+ window.clearTimeout(pollTimer.current);
+ pollTimer.current = null;
+ }
+ };
+
+ const stopPolling = () => {
+ isActiveRef.current = false;
+ clearPoll();
+ };
+
+ useEffect(() => {
+ if (modalState === 'ACTIVE') {
+ isActiveRef.current = true;
+ setPhase('confirm');
+ setError(null);
+ setSummary(null);
+ }
+ return stopPolling;
+ }, [modalState, document]);
+
+ const closeModal = () => {
+ stopPolling();
+ setModalState('INACTIVE');
+ };
+
+ const fail = (message?: string) => {
+ setError(message ?? t('settings.sources.wiki.convert.errors.generic'));
+ setPhase('error');
+ };
+
+ const poll = (taskId: string) => {
+ pollTaskOnce(userService, taskId, token)
+ .then((result) => {
+ if (!isActiveRef.current) return;
+ if (result.status === 'pending') {
+ pollTimer.current = window.setTimeout(
+ () => poll(taskId),
+ POLL_INTERVAL_MS,
+ );
+ return;
+ }
+ if (result.status === 'done') {
+ setSummary(result.summary);
+ setPhase('summary');
+ onConverted();
+ return;
+ }
+ fail(result.message);
+ })
+ .catch(() => {
+ if (!isActiveRef.current) return;
+ fail();
+ });
+ };
+
+ const handleConvert = async () => {
+ if (!document?.id) return;
+ setPhase('converting');
+ setError(null);
+ const start = await startWikiConversion(userService, document.id, token);
+ if (start.status === 'enabled') {
+ setSummary({ pagesCreated: 0, skipped: [] });
+ setPhase('summary');
+ onConverted();
+ return;
+ }
+ if (start.status === 'task') {
+ poll(start.taskId);
+ return;
+ }
+ if (start.status === 'forbidden') {
+ fail(t('settings.sources.wiki.convert.errors.forbidden'));
+ return;
+ }
+ if (start.status === 'conflict') {
+ fail(start.message ?? t('settings.sources.wiki.convert.errors.conflict'));
+ return;
+ }
+ fail(start.message);
+ };
+
+ return (
+ !o && closeModal()}
+ hideTitle
+ title={t('settings.sources.wiki.convert.title')}
+ size="md"
+ mobileVariant="sheet"
+ className="max-w-[480px]"
+ isPerformingTask={phase === 'converting'}
+ >
+
+
+ {t('settings.sources.wiki.convert.title')}
+
+
+ {phase === 'confirm' && (
+ <>
+
+ {document?.name
+ ? t('settings.sources.wiki.convert.intro', {
+ name: document.name,
+ })
+ : t('settings.sources.wiki.convert.introGeneric')}
+
+
+ - {t('settings.sources.wiki.convert.costReparse')}
+ - {t('settings.sources.wiki.convert.costSkipped')}
+ - {t('settings.sources.wiki.convert.costIrreversible')}
+
+
+
+
+
+ >
+ )}
+
+ {phase === 'converting' && (
+
+
+
+ {t('settings.sources.wiki.convert.inProgress')}
+
+
+ )}
+
+ {phase === 'summary' && summary && (
+ <>
+
+ {[
+ t('settings.sources.wiki.convert.summaryPages', {
+ count: summary.pagesCreated,
+ }),
+ t('settings.sources.wiki.convert.summarySkipped', {
+ count: summary.skipped.length,
+ }),
+ ].join(' · ')}
+
+ {summary.skipped.length > 0 && (
+
+
+ {t('settings.sources.wiki.convert.skippedHeading')}
+
+
+ {summary.skipped.map((s) => (
+ -
+ {s.file}
+ {s.reason ? ` — ${s.reason}` : ''}
+
+ ))}
+
+
+ )}
+
+
+
+ >
+ )}
+
+ {phase === 'error' && (
+ <>
+
+ {error}
+
+
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx
index 0206cde4..8cb86932 100644
--- a/frontend/src/settings/Sources.tsx
+++ b/frontend/src/settings/Sources.tsx
@@ -1,4 +1,9 @@
-import { Search as SearchIcon, SlidersHorizontal, Users } from 'lucide-react';
+import {
+ BookOpen,
+ Search as SearchIcon,
+ SlidersHorizontal,
+ Users,
+} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
@@ -45,6 +50,8 @@ import { formatDate } from '../utils/dateTimeUtils';
import FileTree from '../components/FileTree';
import ConnectorTree from '../components/ConnectorTree';
import Chunks from '../components/Chunks';
+import WikiViewer from '../components/WikiViewer';
+import ConvertToWikiModal from './ConvertToWikiModal';
import SourceConfigModal from './SourceConfigModal';
type SourceMenuOption = {
@@ -110,6 +117,9 @@ export default function Sources({
);
const [configModalState, setConfigModalState] =
useState('INACTIVE');
+ const [documentToConvert, setDocumentToConvert] = useState(null);
+ const [convertModalState, setConvertModalState] =
+ useState('INACTIVE');
const [syncMenuState, setSyncMenuState] = useState<{
isOpen: boolean;
docId: string | null;
@@ -327,10 +337,16 @@ export default function Sources({
index: number,
document: Doc,
): SourceMenuOption[] => {
+ const isWiki = document.config?.kind === 'wiki' || document.type === 'wiki';
+ // 'team' viewers cannot write; convert is owner/editor only.
+ const canEdit =
+ document.ownership !== 'team' || document.team_access === 'editor';
const actions: SourceMenuOption[] = [
{
icon: EyeView,
- label: t('settings.sources.view'),
+ label: isWiki
+ ? t('settings.sources.wiki.view')
+ : t('settings.sources.view'),
onClick: () => {
setDocumentToView(document);
},
@@ -380,7 +396,7 @@ export default function Sources({
});
}
- if (document.id) {
+ if (document.id && !isWiki) {
actions.push({
icon: SlidersHorizontal,
label: t('settings.sources.editConfig'),
@@ -394,6 +410,26 @@ export default function Sources({
});
}
+ if (
+ document.id &&
+ !isWiki &&
+ canEdit &&
+ document.ingestStatus !== 'processing' &&
+ document.ingestStatus !== 'failed'
+ ) {
+ actions.push({
+ icon: BookOpen,
+ label: t('settings.sources.wiki.convert.action'),
+ onClick: () => {
+ setDocumentToConvert(document);
+ setConvertModalState('ACTIVE');
+ },
+ iconWidth: 16,
+ iconHeight: 16,
+ variant: 'default',
+ });
+ }
+
// Sharing is an owner-only action: hide it for sources shared into the
// user's workspace by a team.
if (document.ownership !== 'team' && document.id) {
@@ -428,7 +464,18 @@ export default function Sources({
return documentToView ? (
- {documentToView.isNested ? (
+ {documentToView.config?.kind === 'wiki' ||
+ documentToView.type === 'wiki' ? (
+ setDocumentToView(undefined)}
+ />
+ ) : documentToView.isNested ? (
documentToView.type === 'connector:file' ? (
setDocumentToView(document)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ setDocumentToView(document);
+ }
+ }}
+ className={`bg-muted dark:bg-accent focus-visible:ring-ring/50 flex h-[130px] w-full cursor-pointer flex-col rounded-2xl p-5 transition-all duration-200 outline-none focus-visible:ring-[3px] ${
actionMenuDocId === docId ||
syncMenuState.docId === docId
? 'scale-[1.05]'
@@ -555,6 +612,7 @@ export default function Sources({
e.stopPropagation()}
>
{syncOptions.map((opt) => (
e.stopPropagation()}
>
{getActionOptions(index, document).map(
(option, idx) => (
@@ -750,6 +809,18 @@ export default function Sources({
document={documentToConfigure}
onReingest={handleReingest}
/>
+
+ {
+ setConvertModalState(state);
+ if (state === 'INACTIVE') {
+ setDocumentToConvert(null);
+ }
+ }}
+ document={documentToConvert}
+ onConverted={() => refreshDocs(undefined, currentPage, rowsPerPage)}
+ />
);
}
diff --git a/frontend/src/settings/wikiConvertUtils.test.ts b/frontend/src/settings/wikiConvertUtils.test.ts
new file mode 100644
index 00000000..41bb482a
--- /dev/null
+++ b/frontend/src/settings/wikiConvertUtils.test.ts
@@ -0,0 +1,116 @@
+import { describe, expect, it, vi } from 'vitest';
+
+import {
+ interpretTaskStatus,
+ pollTaskOnce,
+ startWikiConversion,
+} from './wikiConvertUtils';
+
+const jsonResponse = (status: number, body: unknown): Response =>
+ ({
+ ok: status >= 200 && status < 300,
+ status,
+ json: () => Promise.resolve(body),
+ }) as unknown as Response;
+
+describe('startWikiConversion', () => {
+ it('returns enabled for a blank source ({enabled:true}, no task)', async () => {
+ const convertToWiki = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(200, { success: true, enabled: true }));
+
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+
+ expect(convertToWiki).toHaveBeenCalledWith('src-1', 'tok');
+ expect(result).toEqual({ status: 'enabled' });
+ });
+
+ it('returns the task id for a fileful source', async () => {
+ const convertToWiki = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(200, { success: true, task_id: 't-9' }));
+
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+
+ expect(result).toEqual({ status: 'task', taskId: 't-9' });
+ });
+
+ it('maps 403 to forbidden', async () => {
+ const convertToWiki = vi.fn().mockResolvedValue(jsonResponse(403, {}));
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+ expect(result).toEqual({ status: 'forbidden' });
+ });
+
+ it('maps 409 to conflict with the server message', async () => {
+ const convertToWiki = vi
+ .fn()
+ .mockResolvedValue(jsonResponse(409, { message: 'still ingesting' }));
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+ expect(result).toEqual({ status: 'conflict', message: 'still ingesting' });
+ });
+
+ it('maps a network throw to a generic error', async () => {
+ const convertToWiki = vi.fn().mockRejectedValue(new Error('offline'));
+ const result = await startWikiConversion({ convertToWiki }, 'src-1', 'tok');
+ expect(result).toEqual({ status: 'error' });
+ });
+});
+
+describe('interpretTaskStatus', () => {
+ it('parses a SUCCESS summary (pages + skipped list)', () => {
+ const result = interpretTaskStatus('SUCCESS', {
+ status: 'converted',
+ pages_created: 12,
+ skipped: [{ file: 'a.png', reason: 'unsupported' }],
+ });
+ expect(result).toEqual({
+ status: 'done',
+ summary: {
+ pagesCreated: 12,
+ skipped: [{ file: 'a.png', reason: 'unsupported' }],
+ },
+ });
+ });
+
+ it('defaults missing summary fields', () => {
+ const result = interpretTaskStatus('SUCCESS', {});
+ expect(result).toEqual({
+ status: 'done',
+ summary: { pagesCreated: 0, skipped: [] },
+ });
+ });
+
+ it('maps FAILURE to failed', () => {
+ expect(interpretTaskStatus('FAILURE', 'boom')).toEqual({
+ status: 'failed',
+ message: 'boom',
+ });
+ });
+
+ it('treats other statuses as pending', () => {
+ expect(interpretTaskStatus('PENDING', null)).toEqual({ status: 'pending' });
+ expect(interpretTaskStatus('STARTED', null)).toEqual({ status: 'pending' });
+ });
+});
+
+describe('pollTaskOnce', () => {
+ it('returns the parsed summary on success', async () => {
+ const getTaskStatus = vi.fn().mockResolvedValue(
+ jsonResponse(200, {
+ status: 'SUCCESS',
+ result: { pages_created: 3, skipped: [] },
+ }),
+ );
+ const result = await pollTaskOnce({ getTaskStatus }, 't-1', 'tok');
+ expect(result).toEqual({
+ status: 'done',
+ summary: { pagesCreated: 3, skipped: [] },
+ });
+ });
+
+ it('returns pending on a non-ok response', async () => {
+ const getTaskStatus = vi.fn().mockResolvedValue(jsonResponse(503, {}));
+ const result = await pollTaskOnce({ getTaskStatus }, 't-1', 'tok');
+ expect(result).toEqual({ status: 'pending' });
+ });
+});
diff --git a/frontend/src/settings/wikiConvertUtils.ts b/frontend/src/settings/wikiConvertUtils.ts
new file mode 100644
index 00000000..8f258407
--- /dev/null
+++ b/frontend/src/settings/wikiConvertUtils.ts
@@ -0,0 +1,89 @@
+export interface SkippedFile {
+ file: string;
+ reason: string;
+}
+
+export interface ConvertSummary {
+ pagesCreated: number;
+ skipped: SkippedFile[];
+}
+
+export type ConvertStart =
+ | { status: 'enabled' }
+ | { status: 'task'; taskId: string }
+ | { status: 'forbidden' }
+ | { status: 'conflict'; message?: string }
+ | { status: 'error'; message?: string };
+
+export type ConvertPoll =
+ | { status: 'pending' }
+ | { status: 'done'; summary: ConvertSummary }
+ | { status: 'failed'; message?: string };
+
+interface ConvertService {
+ convertToWiki: (sourceId: string, token: string | null) => Promise;
+ getTaskStatus: (taskId: string, token: string | null) => Promise;
+}
+
+export async function startWikiConversion(
+ service: Pick,
+ sourceId: string,
+ token: string | null,
+): Promise {
+ let response: Response;
+ try {
+ response = await service.convertToWiki(sourceId, token);
+ } catch {
+ return { status: 'error' };
+ }
+ const data = await response.json().catch(() => ({}));
+ if (response.ok && data?.success) {
+ if (data.task_id) return { status: 'task', taskId: data.task_id };
+ return { status: 'enabled' };
+ }
+ if (response.status === 403) return { status: 'forbidden' };
+ if (response.status === 409)
+ return { status: 'conflict', message: data?.message };
+ return { status: 'error', message: data?.message };
+}
+
+function parseSummary(result: unknown): ConvertSummary {
+ const r = (result || {}) as Record;
+ const skipped = Array.isArray(r.skipped) ? (r.skipped as SkippedFile[]) : [];
+ return {
+ pagesCreated: Number(r.pages_created) || 0,
+ skipped,
+ };
+}
+
+export function interpretTaskStatus(
+ status: string | undefined,
+ result: unknown,
+): ConvertPoll {
+ if (status === 'SUCCESS')
+ return { status: 'done', summary: parseSummary(result) };
+ if (status === 'FAILURE') {
+ const message =
+ typeof result === 'string'
+ ? result
+ : ((result as Record)?.message as string | undefined);
+ return { status: 'failed', message };
+ }
+ return { status: 'pending' };
+}
+
+export async function pollTaskOnce(
+ service: Pick,
+ taskId: string,
+ token: string | null,
+): Promise {
+ let response: Response;
+ try {
+ response = await service.getTaskStatus(taskId, token);
+ } catch {
+ return { status: 'pending' };
+ }
+ if (!response.ok) return { status: 'pending' };
+ const data = await response.json().catch(() => ({}));
+ return interpretTaskStatus(data?.status, data?.result);
+}
diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx
index 8affad28..1ee3144f 100644
--- a/frontend/src/upload/Upload.tsx
+++ b/frontend/src/upload/Upload.tsx
@@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector, useStore } from 'react-redux';
import type { RootState } from '../store';
+import userService from '../api/services/userService';
import { getSessionToken } from '../utils/providerUtils';
import { Button } from '../components/ui/button';
import { Input } from '../components/ui/input';
@@ -210,6 +211,34 @@ function Upload({
/>
);
+ case 'textarea':
+ return (
+
+
+
+ );
case 'local_file_picker':
return (
@@ -712,6 +741,42 @@ function Upload({
xhr.send(formData);
};
+ const createWiki = (clientTaskId: string) => {
+ dispatch(
+ updateUploadTask({
+ id: clientTaskId,
+ updates: { status: 'training', progress: 0 },
+ }),
+ );
+ userService
+ .createWiki(
+ {
+ name: ingestor.name,
+ initial_content: String(ingestor.config.initial_content ?? ''),
+ },
+ token,
+ )
+ .then((response) => response.json())
+ .then((data) => {
+ if (!data?.success) {
+ handleTaskFailure(clientTaskId, data?.message);
+ return;
+ }
+ dispatch(
+ updateUploadTask({
+ id: clientTaskId,
+ updates: {
+ sourceId: data.source_id,
+ status: 'completed',
+ progress: 100,
+ },
+ }),
+ );
+ onSuccessfulUpload?.();
+ })
+ .catch(() => handleTaskFailure(clientTaskId));
+ };
+
const handleClose = useCallback(() => {
resetUploaderState();
setModalState('INACTIVE');
@@ -745,7 +810,9 @@ function Upload({
}),
);
- if (hasLocalFilePicker) {
+ if (ingestor.type === 'wiki') {
+ createWiki(clientTaskId);
+ } else if (hasLocalFilePicker) {
uploadFile(clientTaskId);
} else {
uploadRemote(clientTaskId);
@@ -968,10 +1035,12 @@ function Upload({
className="w-full"
/>
{renderFormFields()}
-
+ {ingestor.type !== 'wiki' && (
+
+ )}
)}
@@ -1004,7 +1073,9 @@ function Upload({
: ''
}`}
>
- {t('modals.uploadDoc.train')}
+ {ingestor.type === 'wiki'
+ ? t('modals.uploadDoc.create')
+ : t('modals.uploadDoc.train')}
)}
diff --git a/frontend/src/upload/types/ingestor.test.ts b/frontend/src/upload/types/ingestor.test.ts
new file mode 100644
index 00000000..7cad192e
--- /dev/null
+++ b/frontend/src/upload/types/ingestor.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+ IngestorDefaultConfigs,
+ IngestorFormSchemas,
+ getIngestorSchema,
+} from './ingestor';
+
+describe('wiki ingestor schema', () => {
+ it('registers a "wiki" ingestor with the New wiki label', () => {
+ const schema = getIngestorSchema('wiki');
+ expect(schema).toBeDefined();
+ expect(schema?.label).toBe('New wiki');
+ });
+
+ it('exposes an optional initial_content textarea field', () => {
+ const schema = getIngestorSchema('wiki');
+ const field = schema?.fields.find((f) => f.name === 'initial_content');
+ expect(field).toBeDefined();
+ expect(field?.type).toBe('textarea');
+ expect(field?.required ?? false).toBe(false);
+ });
+
+ it('does not require a remote file picker (create-only, no ingest)', () => {
+ const schema = getIngestorSchema('wiki');
+ const pickerTypes = (schema?.fields ?? []).map((f) => f.type);
+ expect(pickerTypes).not.toContain('local_file_picker');
+ expect(pickerTypes).not.toContain('remote_file_picker');
+ });
+
+ it('provides a default config for the wiki type', () => {
+ expect(IngestorDefaultConfigs.wiki).toEqual({
+ name: '',
+ config: { initial_content: '' },
+ });
+ });
+
+ it('keeps the wiki schema discoverable in the form schema list', () => {
+ expect(IngestorFormSchemas.some((s) => s.key === 'wiki')).toBe(true);
+ });
+});
diff --git a/frontend/src/upload/types/ingestor.ts b/frontend/src/upload/types/ingestor.ts
index b30d73a8..b3bf41f2 100644
--- a/frontend/src/upload/types/ingestor.ts
+++ b/frontend/src/upload/types/ingestor.ts
@@ -7,6 +7,7 @@ import DriveIcon from '../../assets/drive.svg';
import S3Icon from '../../assets/s3.svg';
import SharePoint from '../../assets/sharepoint.svg';
import ConfluenceIcon from '../../assets/confluence.svg';
+import BookIcon from '../../assets/book-mono.svg';
export type IngestorType =
| 'confluence'
@@ -17,7 +18,8 @@ export type IngestorType =
| 'google_drive'
| 'local_file'
| 's3'
- | 'share_point';
+ | 'share_point'
+ | 'wiki';
export interface IngestorConfig {
type: IngestorType | null;
@@ -37,6 +39,7 @@ export type FieldType =
| 'number'
| 'enum'
| 'boolean'
+ | 'textarea'
| 'local_file_picker'
| 'remote_file_picker'
| 'google_drive_picker'
@@ -235,6 +238,20 @@ export const IngestorFormSchemas: IngestorSchema[] = [
},
],
},
+ {
+ key: 'wiki',
+ label: 'New wiki',
+ icon: BookIcon,
+ heading: 'Create a living wiki',
+ fields: [
+ {
+ name: 'initial_content',
+ label: 'Initial content (optional)',
+ type: 'textarea',
+ required: false,
+ },
+ ],
+ },
];
export const IngestorDefaultConfigs: Record<
@@ -289,6 +306,12 @@ export const IngestorDefaultConfigs: Record<
folder_ids: '',
},
},
+ wiki: {
+ name: '',
+ config: {
+ initial_content: '',
+ },
+ },
};
export interface IngestorOption {
diff --git a/tests/agents/tools/test_wiki.py b/tests/agents/tools/test_wiki.py
new file mode 100644
index 00000000..664a175c
--- /dev/null
+++ b/tests/agents/tools/test_wiki.py
@@ -0,0 +1,583 @@
+"""Tests for application/agents/tools/wiki.py.
+
+A fake repository mirrors the WikiPagesRepository methods the tool calls;
+``db_session`` / ``db_readonly`` are stubbed with a no-op context manager and
+the re-embed task + directory-structure rebuild are patched so no live services
+are needed.
+"""
+
+from __future__ import annotations
+
+import hashlib
+import uuid
+from contextlib import contextmanager
+from unittest.mock import MagicMock
+
+import pytest
+
+from application.storage.db.repositories.wiki_pages import WikiPageConflict
+
+
+def _hash(content: str) -> str:
+ return hashlib.sha256(content.encode("utf-8")).hexdigest()
+
+
+class _FakeWikiRepo:
+ _store: dict[tuple[str, str], dict] = {}
+
+ def __init__(self, conn=None) -> None:
+ self._conn = conn
+
+ @classmethod
+ def reset(cls) -> None:
+ cls._store = {}
+
+ def upsert(
+ self,
+ source_id,
+ path,
+ content,
+ title=None,
+ updated_by=None,
+ updated_via=None,
+ expected_version=None,
+ ):
+ key = (source_id, path)
+ existing = self._store.get(key)
+ content_hash = _hash(content)
+ if existing is not None and existing.get("content_hash") == content_hash:
+ return existing
+ if expected_version is not None and existing is not None:
+ if existing["version"] != expected_version:
+ raise WikiPageConflict("version changed")
+ existing.update(
+ {
+ "content": content,
+ "title": title,
+ "content_hash": content_hash,
+ "updated_by": updated_by,
+ "updated_via": updated_via,
+ "version": existing["version"] + 1,
+ }
+ )
+ return existing
+ if existing is not None:
+ existing.update(
+ {
+ "content": content,
+ "title": title,
+ "content_hash": content_hash,
+ "updated_by": updated_by,
+ "updated_via": updated_via,
+ "version": existing["version"] + 1,
+ }
+ )
+ return existing
+ row = {
+ "id": str(uuid.uuid4()),
+ "source_id": source_id,
+ "path": path,
+ "content": content,
+ "title": title,
+ "content_hash": content_hash,
+ "updated_by": updated_by,
+ "updated_via": updated_via,
+ "version": 1,
+ "token_count": len(content),
+ }
+ self._store[key] = row
+ return row
+
+ def get_by_path(self, source_id, path):
+ return self._store.get((source_id, path))
+
+ def list_by_prefix(self, source_id, prefix):
+ return [
+ r
+ for (s, p), r in self._store.items()
+ if s == source_id and p.startswith(prefix)
+ ]
+
+ def list_for_source(self, source_id):
+ return [r for (s, _p), r in self._store.items() if s == source_id]
+
+ def delete_by_path(self, source_id, path):
+ return 1 if self._store.pop((source_id, path), None) else 0
+
+ def delete_by_prefix(self, source_id, prefix):
+ keys = [
+ k for k in self._store if k[0] == source_id and k[1].startswith(prefix)
+ ]
+ for k in keys:
+ del self._store[k]
+ return len(keys)
+
+ def update_path(self, source_id, old_path, new_path):
+ if (source_id, new_path) in self._store:
+ return False
+ row = self._store.pop((source_id, old_path), None)
+ if row is None:
+ return False
+ row["path"] = new_path
+ self._store[(source_id, new_path)] = row
+ return True
+
+
+@contextmanager
+def _noop_conn():
+ yield None
+
+
+@pytest.fixture
+def reembed_mock():
+ return MagicMock()
+
+
+@pytest.fixture
+def rebuild_mock():
+ return MagicMock()
+
+
+@pytest.fixture
+def patched_wiki(monkeypatch, reembed_mock, rebuild_mock):
+ _FakeWikiRepo.reset()
+ monkeypatch.setattr(
+ "application.agents.tools.wiki.WikiPagesRepository", _FakeWikiRepo
+ )
+ monkeypatch.setattr("application.agents.tools.wiki.db_session", _noop_conn)
+ monkeypatch.setattr("application.agents.tools.wiki.db_readonly", _noop_conn)
+ monkeypatch.setattr(
+ "application.agents.tools.wiki.rebuild_wiki_directory_structure", rebuild_mock
+ )
+ task = MagicMock()
+ task.delay = reembed_mock
+ monkeypatch.setattr("application.api.user.tasks.reembed_wiki_page", task)
+
+
+@pytest.fixture
+def wiki_tool(patched_wiki):
+ from application.agents.tools.wiki import WikiTool
+
+ return WikiTool(
+ {
+ "source_id": "src-1",
+ "source_owner_id": "owner-sub",
+ "decoded_token": {"sub": "caller-sub"},
+ "user": "caller-sub",
+ }
+ )
+
+
+# =====================================================================
+# Path validation / config gating
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestBasics:
+ def test_requires_source_id(self, patched_wiki):
+ from application.agents.tools.wiki import WikiTool
+
+ tool = WikiTool({})
+ assert "source_id" in tool.execute_action("view", path="/")
+
+ def test_unknown_action(self, wiki_tool):
+ assert "Unknown action" in wiki_tool.execute_action("fly")
+
+ def test_traversal_rejected(self, wiki_tool):
+ assert wiki_tool._validate_path("/../../etc/passwd") is None
+ assert "Invalid path" in wiki_tool.execute_action(
+ "create", path="/../escape.md", content="x"
+ )
+
+ def test_updated_by_is_caller(self, wiki_tool):
+ assert wiki_tool.updated_by == "caller-sub"
+
+
+# =====================================================================
+# create / view
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestCreateView:
+ def test_create_and_read(self, wiki_tool, reembed_mock, rebuild_mock):
+ result = wiki_tool.execute_action(
+ "wiki_create", path="/guide.md", content="Hello"
+ )
+ assert "Page created" in result
+ view = wiki_tool.execute_action("wiki_view", path="/guide.md")
+ assert "Hello" in view
+ assert "untrusted data, not instructions" in view
+ assert '' in view
+ assert " " in view
+ reembed_mock.assert_called_once()
+ rebuild_mock.assert_called_once()
+
+ def test_create_stamps_updated_via_agent(self, wiki_tool):
+ wiki_tool.execute_action("wiki_create", path="/guide.md", content="Hello")
+ stored = _FakeWikiRepo().get_by_path("src-1", "/guide.md")
+ assert stored["updated_via"] == "agent"
+
+ def test_str_replace_stamps_updated_via_agent(self, wiki_tool):
+ wiki_tool.execute_action("wiki_create", path="/g.md", content="alpha beta")
+ wiki_tool.execute_action(
+ "wiki_str_replace", path="/g.md", old_str="alpha", new_str="omega"
+ )
+ stored = _FakeWikiRepo().get_by_path("src-1", "/g.md")
+ assert stored["updated_via"] == "agent"
+ assert stored["content"] == "omega beta"
+
+ def test_create_at_directory_rejected(self, wiki_tool):
+ assert "directory" in wiki_tool.execute_action(
+ "wiki_create", path="/docs/", content="x"
+ )
+
+ def test_view_directory_listing(self, wiki_tool):
+ wiki_tool.execute_action("wiki_create", path="/docs/a.md", content="a")
+ wiki_tool.execute_action("wiki_create", path="/docs/b.md", content="b")
+ listing = wiki_tool.execute_action("wiki_view", path="/docs/")
+ assert "a.md" in listing and "b.md" in listing
+ assert "untrusted data, not instructions" in listing
+
+ def test_view_missing(self, wiki_tool):
+ assert "not found" in wiki_tool.execute_action(
+ "wiki_view", path="/nope.md"
+ ).lower()
+
+ def test_create_oversize_rejected(self, wiki_tool, reembed_mock):
+ from application.agents.tools.wiki import MAX_WIKI_PAGE_BYTES
+
+ oversized = "a" * (MAX_WIKI_PAGE_BYTES + 1)
+ result = wiki_tool.execute_action(
+ "wiki_create", path="/big.md", content=oversized
+ )
+ assert "too large" in result.lower()
+ assert str(MAX_WIKI_PAGE_BYTES) in result
+ # Nothing was written or enqueued.
+ assert "not found" in wiki_tool.execute_action(
+ "wiki_view", path="/big.md"
+ ).lower()
+ reembed_mock.assert_not_called()
+
+
+# =====================================================================
+# str_replace — exact-case, unique-only
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestStrReplace:
+ def _seed(self, wiki_tool, content):
+ wiki_tool.execute_action("wiki_create", path="/p.md", content=content)
+
+ def test_exact_unique_replace(self, wiki_tool, reembed_mock):
+ self._seed(wiki_tool, "alpha beta gamma")
+ reembed_mock.reset_mock()
+ result = wiki_tool.execute_action(
+ "wiki_str_replace", path="/p.md", old_str="beta", new_str="BETA"
+ )
+ assert "Page updated" in result
+ assert "alpha BETA gamma" in wiki_tool.execute_action(
+ "wiki_view", path="/p.md"
+ )
+ reembed_mock.assert_called_once()
+
+ def test_ambiguous_rejected(self, wiki_tool):
+ self._seed(wiki_tool, "x x x")
+ result = wiki_tool.execute_action(
+ "wiki_str_replace", path="/p.md", old_str="x", new_str="y"
+ )
+ assert "occurs 3 times" in result
+ assert "x x x" in wiki_tool.execute_action("wiki_view", path="/p.md")
+
+ def test_absent_rejected(self, wiki_tool):
+ self._seed(wiki_tool, "hello")
+ result = wiki_tool.execute_action(
+ "wiki_str_replace", path="/p.md", old_str="zzz", new_str="y"
+ )
+ assert "not found" in result.lower()
+
+ def test_case_mismatch_not_replaced(self, wiki_tool):
+ self._seed(wiki_tool, "Hello World")
+ result = wiki_tool.execute_action(
+ "wiki_str_replace", path="/p.md", old_str="hello", new_str="bye"
+ )
+ assert "not found" in result.lower()
+ assert "Hello World" in wiki_tool.execute_action("wiki_view", path="/p.md")
+
+
+# =====================================================================
+# insert / delete / rename
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestInsertDeleteRename:
+ def test_insert(self, wiki_tool, reembed_mock):
+ wiki_tool.execute_action("wiki_create", path="/p.md", content="line1\nline3")
+ reembed_mock.reset_mock()
+ result = wiki_tool.execute_action(
+ "wiki_insert", path="/p.md", insert_line=2, insert_text="line2"
+ )
+ assert "inserted" in result
+ assert "line1\nline2\nline3" in wiki_tool.execute_action(
+ "wiki_view", path="/p.md"
+ )
+ reembed_mock.assert_called_once()
+
+ def test_delete_page(self, wiki_tool, reembed_mock, rebuild_mock):
+ wiki_tool.execute_action("wiki_create", path="/p.md", content="x")
+ reembed_mock.reset_mock()
+ rebuild_mock.reset_mock()
+ result = wiki_tool.execute_action("wiki_delete", path="/p.md")
+ assert "Deleted" in result
+ assert "not found" in wiki_tool.execute_action(
+ "wiki_view", path="/p.md"
+ ).lower()
+ reembed_mock.assert_called_once()
+ rebuild_mock.assert_called_once()
+
+ def test_delete_missing(self, wiki_tool):
+ assert "not found" in wiki_tool.execute_action(
+ "wiki_delete", path="/nope.md"
+ ).lower()
+
+ def test_delete_directory_reembeds_each_page(
+ self, wiki_tool, reembed_mock
+ ):
+ wiki_tool.execute_action("wiki_create", path="/docs/a.md", content="a")
+ wiki_tool.execute_action("wiki_create", path="/docs/b.md", content="b")
+ wiki_tool.execute_action("wiki_create", path="/docs/c.md", content="c")
+ reembed_mock.reset_mock()
+ result = wiki_tool.execute_action("wiki_delete", path="/docs/")
+ assert "3 page(s)" in result
+ # One purge re-embed enqueued per deleted page.
+ assert reembed_mock.call_count == 3
+
+ def test_rename(self, wiki_tool, reembed_mock):
+ wiki_tool.execute_action("wiki_create", path="/old.md", content="x")
+ reembed_mock.reset_mock()
+ result = wiki_tool.execute_action(
+ "wiki_rename", old_path="/old.md", new_path="/new.md"
+ )
+ assert "Renamed" in result
+ assert "x" in wiki_tool.execute_action("wiki_view", path="/new.md")
+ # rename enqueues a purge of the old path and an embed of the new path.
+ assert reembed_mock.call_count == 2
+
+ def test_rename_rejects_existing_target(self, wiki_tool):
+ wiki_tool.execute_action("wiki_create", path="/a.md", content="a")
+ wiki_tool.execute_action("wiki_create", path="/b.md", content="b")
+ result = wiki_tool.execute_action(
+ "wiki_rename", old_path="/a.md", new_path="/b.md"
+ )
+ assert "already exists" in result
+ assert "a" in wiki_tool.execute_action("wiki_view", path="/a.md")
+ assert "b" in wiki_tool.execute_action("wiki_view", path="/b.md")
+
+
+# =====================================================================
+# Owner-contract: re-embed enqueued as the OWNER
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestOwnerContract:
+ def test_reembed_passes_owner_as_user(self, wiki_tool, reembed_mock):
+ wiki_tool.execute_action("wiki_create", path="/p.md", content="hi")
+ _args, kwargs = reembed_mock.call_args
+ assert kwargs["user"] == "owner-sub"
+ assert kwargs["user"] != "caller-sub"
+
+ def test_reembed_passes_per_page_idempotency_key(
+ self, wiki_tool, reembed_mock
+ ):
+ wiki_tool.execute_action("wiki_create", path="/a.md", content="hi")
+ _args, kwargs_a = reembed_mock.call_args
+ key_a = kwargs_a["idempotency_key"]
+ assert key_a == f"reembed-wiki:src-1:/a.md:{_hash('hi')}"
+
+ wiki_tool.execute_action("wiki_create", path="/b.md", content="hi")
+ _args, kwargs_b = reembed_mock.call_args
+ key_b = kwargs_b["idempotency_key"]
+ # Same content on a different page must NOT collide on the key.
+ assert key_b == f"reembed-wiki:src-1:/b.md:{_hash('hi')}"
+ assert key_a != key_b
+
+
+# =====================================================================
+# Optimistic concurrency
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestOptimisticConcurrency:
+ def test_stale_version_conflict_no_write(self, wiki_tool, monkeypatch):
+ wiki_tool.execute_action("wiki_create", path="/p.md", content="orig")
+
+ original_upsert = _FakeWikiRepo.upsert
+
+ def conflicting_upsert(self, *args, **kwargs):
+ if kwargs.get("expected_version") is not None:
+ raise WikiPageConflict("version changed")
+ return original_upsert(self, *args, **kwargs)
+
+ monkeypatch.setattr(_FakeWikiRepo, "upsert", conflicting_upsert)
+ result = wiki_tool.execute_action(
+ "wiki_str_replace", path="/p.md", old_str="orig", new_str="new"
+ )
+ assert "changed" in result.lower()
+ # No mutation landed.
+ monkeypatch.setattr(_FakeWikiRepo, "upsert", original_upsert)
+ assert "orig" in wiki_tool.execute_action("wiki_view", path="/p.md")
+
+
+# =====================================================================
+# Injection helper + build_agent authz gating
+# =====================================================================
+
+
+@pytest.mark.unit
+class TestInjection:
+ def test_add_wiki_tool_entry_has_id(self):
+ from application.agents.tools.wiki import WIKI_TOOL_ID, add_wiki_tool
+
+ tools_dict = {}
+ add_wiki_tool(
+ tools_dict,
+ {
+ "source_id": "s1",
+ "source_owner_id": "owner",
+ "decoded_token": {"sub": "c"},
+ "user": "c",
+ },
+ )
+ assert WIKI_TOOL_ID in tools_dict
+ assert tools_dict[WIKI_TOOL_ID]["id"] == WIKI_TOOL_ID
+ assert tools_dict[WIKI_TOOL_ID]["name"] == "wiki"
+ assert tools_dict[WIKI_TOOL_ID]["config"]["source_owner_id"] == "owner"
+
+ def test_add_wiki_tool_skips_without_owner(self):
+ from application.agents.tools.wiki import WIKI_TOOL_ID, add_wiki_tool
+
+ tools_dict = {}
+ add_wiki_tool(
+ tools_dict,
+ {"source_id": "s1", "source_owner_id": None, "decoded_token": {}},
+ )
+ assert WIKI_TOOL_ID not in tools_dict
+
+
+@pytest.mark.unit
+class TestBuildAgentGating:
+ def _processor(self, all_sources, caller="caller"):
+ from application.api.answer.services.stream_processor import StreamProcessor
+
+ proc = StreamProcessor.__new__(StreamProcessor)
+ proc.all_sources = all_sources
+ proc.decoded_token = {"sub": caller} if caller else None
+ return proc
+
+ def test_writable_wiki_source_injected(self, monkeypatch):
+ proc = self._processor([{"id": "wiki-src"}])
+
+ class _SrcRepo:
+ def __init__(self, conn):
+ pass
+
+ def get_any(self, sid, owner):
+ return {"id": sid, "config": {"kind": "wiki"}}
+
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.SourcesRepository",
+ _SrcRepo,
+ )
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.db_readonly",
+ _noop_conn,
+ )
+ monkeypatch.setattr(
+ "application.api.user.team_sharing.effective_write_owner",
+ lambda conn, rt, rid, uid: "owner-x",
+ )
+ cfg = proc._build_wiki_config()
+ assert cfg is not None
+ assert cfg["source_id"] == "wiki-src"
+ assert cfg["source_owner_id"] == "owner-x"
+
+ def test_viewer_gets_no_wiki_tool(self, monkeypatch):
+ proc = self._processor([{"id": "wiki-src"}])
+
+ class _SrcRepo:
+ def __init__(self, conn):
+ pass
+
+ def get_any(self, sid, owner):
+ return {"id": sid, "config": {"kind": "wiki"}}
+
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.SourcesRepository",
+ _SrcRepo,
+ )
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.db_readonly",
+ _noop_conn,
+ )
+ # Viewer: effective_write_owner returns None.
+ monkeypatch.setattr(
+ "application.api.user.team_sharing.effective_write_owner",
+ lambda conn, rt, rid, uid: None,
+ )
+ assert proc._build_wiki_config() is None
+
+ def test_non_wiki_source_skipped(self, monkeypatch):
+ proc = self._processor([{"id": "classic-src"}])
+
+ class _SrcRepo:
+ def __init__(self, conn):
+ pass
+
+ def get_any(self, sid, owner):
+ return {"id": sid, "config": {"kind": "classic"}}
+
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.SourcesRepository",
+ _SrcRepo,
+ )
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.db_readonly",
+ _noop_conn,
+ )
+ monkeypatch.setattr(
+ "application.api.user.team_sharing.effective_write_owner",
+ lambda conn, rt, rid, uid: "owner-x",
+ )
+ assert proc._build_wiki_config() is None
+
+ def test_two_writable_wiki_sources_uses_first(self, monkeypatch):
+ proc = self._processor([{"id": "wiki-1"}, {"id": "wiki-2"}])
+
+ class _SrcRepo:
+ def __init__(self, conn):
+ pass
+
+ def get_any(self, sid, owner):
+ return {"id": sid, "config": {"kind": "wiki"}}
+
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.SourcesRepository",
+ _SrcRepo,
+ )
+ monkeypatch.setattr(
+ "application.api.answer.services.stream_processor.db_readonly",
+ _noop_conn,
+ )
+ monkeypatch.setattr(
+ "application.api.user.team_sharing.effective_write_owner",
+ lambda conn, rt, rid, uid: "owner-x",
+ )
+ cfg = proc._build_wiki_config()
+ assert cfg is not None
+ # v1 binds the first writable wiki source; the extra is skipped.
+ assert cfg["source_id"] == "wiki-1"
diff --git a/tests/api/user/sources/test_routes.py b/tests/api/user/sources/test_routes.py
index e3653c0f..739e1220 100644
--- a/tests/api/user/sources/test_routes.py
+++ b/tests/api/user/sources/test_routes.py
@@ -1029,3 +1029,108 @@ class TestSourceConfigResource:
# owner-scoped read sees it.
got = SourcesRepository(pg_conn).get_any(sid, owner)
assert got["config"]["retrieval"]["chunks"] == 9
+
+ def test_kind_flip_to_wiki_rejected(self, app, pg_conn):
+ # Flipping kind to wiki must route through /wiki/convert, not config.
+ from application.api.user.sources.routes import SourceConfigResource
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-cfg-wiki-flip"
+ src = _seed_source(pg_conn, user, name="cfg-wiki", type="file")
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/config",
+ method="PATCH",
+ json={"kind": "wiki"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = SourceConfigResource().patch(sid)
+
+ assert response.status_code == 400
+ # The kind must NOT have silently flipped.
+ from application.storage.db.source_config import SourceConfig
+
+ got = SourcesRepository(pg_conn).get_any(sid, user)
+ assert SourceConfig.parse(got.get("config")).kind != "wiki"
+
+ def test_other_edits_work_on_wiki_source(self, app, pg_conn):
+ # A wiki source can still edit retrieval (kind stays wiki, no reject).
+ from application.api.user.sources.routes import SourceConfigResource
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-cfg-wiki-edit"
+ src = _seed_source(
+ pg_conn, user, name="wiki-cfg", type="wiki",
+ config={"kind": "wiki"},
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/config",
+ method="PATCH",
+ json={"kind": "wiki", "retrieval": {"chunks": 4}},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = SourceConfigResource().patch(sid)
+
+ assert response.status_code == 200
+ got = SourcesRepository(pg_conn).get_any(sid, user)
+ assert got["config"]["retrieval"]["chunks"] == 4
+
+ def test_partial_edit_preserves_wiki_kind(self, app, pg_conn):
+ # A partial edit that OMITS kind must not demote a wiki to classic
+ # (SourceConfig.kind defaults to "classic" on a full-replace write).
+ from application.api.user.sources.routes import SourceConfigResource
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.source_config import SourceConfig
+
+ user = "u-cfg-wiki-partial"
+ src = _seed_source(
+ pg_conn, user, name="wiki-partial", type="wiki",
+ config={"kind": "wiki"},
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/config",
+ method="PATCH",
+ json={"retrieval": {"chunks": 6}},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = SourceConfigResource().patch(sid)
+
+ assert response.status_code == 200
+ assert response.json["config"]["kind"] == "wiki"
+ got = SourcesRepository(pg_conn).get_any(sid, user)
+ assert SourceConfig.parse(got.get("config")).kind == "wiki"
+ assert got["config"]["retrieval"]["chunks"] == 6
+
+ def test_explicit_kind_demotion_from_wiki_rejected(self, app, pg_conn):
+ # Demoting wiki -> classic via config is rejected; use /wiki/convert.
+ from application.api.user.sources.routes import SourceConfigResource
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.source_config import SourceConfig
+
+ user = "u-cfg-wiki-demote"
+ src = _seed_source(
+ pg_conn, user, name="wiki-demote", type="wiki",
+ config={"kind": "wiki"},
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/config",
+ method="PATCH",
+ json={"kind": "classic"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = SourceConfigResource().patch(sid)
+
+ assert response.status_code == 400
+ got = SourcesRepository(pg_conn).get_any(sid, user)
+ assert SourceConfig.parse(got.get("config")).kind == "wiki"
diff --git a/tests/api/user/sources/test_wiki_routes.py b/tests/api/user/sources/test_wiki_routes.py
new file mode 100644
index 00000000..f3a7303c
--- /dev/null
+++ b/tests/api/user/sources/test_wiki_routes.py
@@ -0,0 +1,602 @@
+"""Tests for the wiki source routes in application/api/user/sources/routes.py."""
+
+import uuid
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pytest
+from flask import Flask
+
+
+@pytest.fixture
+def app():
+ return Flask(__name__)
+
+
+@contextmanager
+def _patch_db(conn):
+ @contextmanager
+ def _yield():
+ yield conn
+
+ with patch(
+ "application.api.user.sources.routes.db_session", _yield
+ ), patch(
+ "application.api.user.sources.routes.db_readonly", _yield
+ ):
+ yield
+
+
+def _grant_team_access(pg_conn, owner, member, source_id, access_level):
+ from application.storage.db.repositories.team_members import (
+ TeamMembersRepository,
+ )
+ from application.storage.db.repositories.team_resource_grants import (
+ TeamResourceGrantsRepository,
+ )
+ from application.storage.db.repositories.teams import TeamsRepository
+
+ team = TeamsRepository(pg_conn).create(
+ "Acme", f"acme-{uuid.uuid4().hex[:8]}", owner
+ )
+ TeamMembersRepository(pg_conn).add_member(
+ team["id"], member, role="team_member"
+ )
+ TeamResourceGrantsRepository(pg_conn).grant(
+ team["id"], "source", source_id, owner_id=owner, granted_by=owner,
+ access_level=access_level,
+ )
+
+
+class TestCreateWikiSource:
+ def test_returns_401_unauthenticated(self, app):
+ from application.api.user.sources.routes import CreateWikiSource
+
+ with app.test_request_context(
+ "/api/sources/wiki", method="POST", json={"name": "w"}
+ ):
+ from flask import request
+ request.decoded_token = None
+ response = CreateWikiSource().post()
+ assert response.status_code == 401
+
+ def test_returns_400_missing_name(self, app, pg_conn):
+ from application.api.user.sources.routes import CreateWikiSource
+
+ with _patch_db(pg_conn), app.test_request_context(
+ "/api/sources/wiki", method="POST", json={}
+ ):
+ from flask import request
+ request.decoded_token = {"sub": "u-wiki"}
+ response = CreateWikiSource().post()
+ assert response.status_code == 400
+
+ def test_creates_row_without_ingest(self, app, pg_conn):
+ from application.api.user.sources.routes import CreateWikiSource
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-wiki-create"
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ) as mock_reembed, patch(
+ "application.api.user.tasks.ingest.delay"
+ ) as mock_ingest, patch(
+ "application.api.user.tasks.reingest_source_task.delay"
+ ) as mock_reingest, app.test_request_context(
+ "/api/sources/wiki", method="POST", json={"name": "My Wiki"}
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = CreateWikiSource().post()
+
+ assert response.status_code == 200
+ source_id = response.json["source_id"]
+ row = SourcesRepository(pg_conn).get_any(source_id, user)
+ assert row["type"] == "wiki"
+ assert row["config"]["kind"] == "wiki"
+ assert int(row["tokens"]) == 0
+ # No seed content → no re-embed, and never any ingest/reingest task.
+ mock_reembed.assert_not_called()
+ mock_ingest.assert_not_called()
+ mock_reingest.assert_not_called()
+
+ def test_seed_page_roundtrips_and_only_seed_reembeds(self, app, pg_conn):
+ from application.api.user.sources.routes import (
+ CreateWikiSource,
+ WikiPage,
+ WIKI_INDEX_PATH,
+ )
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-wiki-seed"
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ) as mock_reembed, patch(
+ "application.api.user.tasks.ingest.delay"
+ ) as mock_ingest, patch(
+ "application.api.user.tasks.reingest_source_task.delay"
+ ) as mock_reingest, app.test_request_context(
+ "/api/sources/wiki",
+ method="POST",
+ json={"name": "Seeded", "initial_content": "# Hello\nworld"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = CreateWikiSource().post()
+
+ assert response.status_code == 200
+ source_id = response.json["source_id"]
+ row = SourcesRepository(pg_conn).get_any(source_id, user)
+ assert int(row["tokens"]) > 0
+ # Re-embed fires once, only for the seed page; no ingest/reingest ever.
+ mock_reembed.assert_called_once()
+ assert mock_reembed.call_args.args[0] == source_id
+ assert mock_reembed.call_args.args[1] == WIKI_INDEX_PATH
+ assert mock_reembed.call_args.kwargs["user"] == user
+ mock_ingest.assert_not_called()
+ mock_reingest.assert_not_called()
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{source_id}/wiki/page?path={WIKI_INDEX_PATH}"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ page_response = WikiPage().get(source_id)
+ assert page_response.status_code == 200
+ assert page_response.json["page"]["content"] == "# Hello\nworld"
+
+
+class TestWikiPages:
+ def test_returns_401_unauthenticated(self, app):
+ from application.api.user.sources.routes import WikiPages
+
+ with app.test_request_context("/api/sources/x/wiki/pages"):
+ from flask import request
+ request.decoded_token = None
+ response = WikiPages().get("x")
+ assert response.status_code == 401
+
+ def test_owner_lists_pages(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPages
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ user = "u-wiki-list"
+ src = SourcesRepository(pg_conn).create(
+ "wiki-list", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ WikiPagesRepository(pg_conn).upsert(
+ sid, "/index.md", "root", updated_by=user, updated_via="agent"
+ )
+ WikiPagesRepository(pg_conn).upsert(sid, "/docs/a.md", "a", updated_by=user)
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/pages"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPages().get(sid)
+ assert response.status_code == 200
+ paths = {p["path"] for p in response.json["pages"]}
+ assert paths == {"/index.md", "/docs/a.md"}
+ via = {p["path"]: p["updated_via"] for p in response.json["pages"]}
+ assert via["/index.md"] == "agent"
+
+ def test_non_owner_without_grant_404(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPages
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ owner = "u-wiki-owner"
+ stranger = "u-wiki-stranger"
+ src = SourcesRepository(pg_conn).create(
+ "wiki-private", user_id=owner, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/pages"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": stranger}
+ response = WikiPages().get(sid)
+ assert response.status_code == 404
+
+ def test_team_viewer_can_read(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPages
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ owner = "alice-wiki"
+ viewer = "bob-wiki-viewer"
+ src = SourcesRepository(pg_conn).create(
+ "wiki-shared", user_id=owner, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "x", updated_by=owner)
+ _grant_team_access(pg_conn, owner, viewer, sid, "viewer")
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/pages"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": viewer}
+ response = WikiPages().get(sid)
+ assert response.status_code == 200
+ assert {p["path"] for p in response.json["pages"]} == {"/index.md"}
+
+
+class TestWikiPage:
+ def test_returns_400_missing_path(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-wiki-page-nopath"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().get(sid)
+ assert response.status_code == 400
+
+ def test_returns_400_traversal_path(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-wiki-page-traversal"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page?path=/../secret.md"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().get(sid)
+ assert response.status_code == 400
+
+ def test_returns_404_unknown_page(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-wiki-page-missing"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page?path=/nope.md"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().get(sid)
+ assert response.status_code == 404
+
+ def test_returns_provenance_and_version(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ user = "u-wiki-page-provenance"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ WikiPagesRepository(pg_conn).upsert(
+ sid, "/index.md", "x", updated_by=user, updated_via="agent"
+ )
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page?path=/index.md"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().get(sid)
+ assert response.status_code == 200
+ page = response.json["page"]
+ assert page["updated_via"] == "agent"
+ assert page["updated_by"] == user
+ assert page["version"] == 1
+ assert page["updated_at"] is not None
+
+ def test_non_owner_without_grant_404(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ owner = "u-wiki-pg-owner"
+ stranger = "u-wiki-pg-stranger"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ WikiPagesRepository(pg_conn).upsert(sid, "/index.md", "secret", updated_by=owner)
+
+ with _patch_db(pg_conn), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page?path=/index.md"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": stranger}
+ response = WikiPage().get(sid)
+ assert response.status_code == 404
+
+
+class TestConvertSourceToWiki:
+ def test_returns_401_unauthenticated(self, app):
+ from application.api.user.sources.routes import ConvertSourceToWiki
+
+ with app.test_request_context(
+ "/api/sources/x/wiki/convert", method="POST"
+ ):
+ from flask import request
+ request.decoded_token = None
+ response = ConvertSourceToWiki().post("x")
+ assert response.status_code == 401
+
+ def test_blank_source_enabled_inline_no_task(self, app, pg_conn):
+ from application.api.user.sources.routes import ConvertSourceToWiki
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.source_config import SourceConfig
+
+ user = "u-convert-blank"
+ src = SourcesRepository(pg_conn).create(
+ "blank", user_id=user, type="file", directory_structure={}
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.convert_source_to_wiki.delay"
+ ) as mock_convert, app.test_request_context(
+ f"/api/sources/{sid}/wiki/convert", method="POST"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = ConvertSourceToWiki().post(sid)
+
+ assert response.status_code == 200
+ assert response.json["converted"] is False
+ assert response.json["enabled"] is True
+ mock_convert.assert_not_called()
+ got = SourcesRepository(pg_conn).get_any(sid, user)
+ cfg = SourceConfig.parse(got.get("config"))
+ assert cfg.kind == "wiki"
+ assert cfg.retrieval.exposure == "agentic_tool"
+
+ def test_fileful_source_enqueues_task(self, app, pg_conn):
+ from application.api.user.sources.routes import ConvertSourceToWiki
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-convert-files"
+ src = SourcesRepository(pg_conn).create(
+ "files", user_id=user, type="file",
+ directory_structure={"a.md": {"type": "text/markdown"}},
+ )
+ sid = str(src["id"])
+
+ fake_task = type("T", (), {"id": "task-xyz"})()
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.convert_source_to_wiki.delay",
+ return_value=fake_task,
+ ) as mock_convert, app.test_request_context(
+ f"/api/sources/{sid}/wiki/convert", method="POST"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = ConvertSourceToWiki().post(sid)
+
+ assert response.status_code == 200
+ assert response.json["task_id"] == "task-xyz"
+ mock_convert.assert_called_once()
+ assert mock_convert.call_args.kwargs["source_id"] == sid
+ assert mock_convert.call_args.kwargs["user"] == user
+ assert mock_convert.call_args.kwargs["idempotency_key"] == (
+ f"convert-wiki:{sid}"
+ )
+
+ def test_in_progress_ingest_rejected_409(self, app, pg_conn):
+ from application.api.user.sources.routes import ConvertSourceToWiki
+ from application.storage.db.repositories.ingest_chunk_progress import (
+ IngestChunkProgressRepository,
+ )
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-convert-ingesting"
+ src = SourcesRepository(pg_conn).create(
+ "ingesting", user_id=user, type="file",
+ directory_structure={"a.md": {"type": "text/markdown"}},
+ )
+ sid = str(src["id"])
+ # An active embed in flight (embedded 0 of 5) → "processing".
+ IngestChunkProgressRepository(pg_conn).init_progress(sid, 5)
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.convert_source_to_wiki.delay"
+ ) as mock_convert, app.test_request_context(
+ f"/api/sources/{sid}/wiki/convert", method="POST"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = ConvertSourceToWiki().post(sid)
+
+ assert response.status_code == 409
+ mock_convert.assert_not_called()
+
+ def test_viewer_rejected_403(self, app, pg_conn):
+ from application.api.user.sources.routes import ConvertSourceToWiki
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ owner = "alice-convert"
+ viewer = "bob-convert-viewer"
+ src = SourcesRepository(pg_conn).create(
+ "files", user_id=owner, type="file",
+ directory_structure={"a.md": {"type": "text/markdown"}},
+ )
+ sid = str(src["id"])
+ _grant_team_access(pg_conn, owner, viewer, sid, "viewer")
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.convert_source_to_wiki.delay"
+ ) as mock_convert, app.test_request_context(
+ f"/api/sources/{sid}/wiki/convert", method="POST"
+ ):
+ from flask import request
+ request.decoded_token = {"sub": viewer}
+ response = ConvertSourceToWiki().post(sid)
+
+ assert response.status_code == 403
+ mock_convert.assert_not_called()
+
+
+class TestWikiPageEdit:
+ def test_returns_401_unauthenticated(self, app):
+ from application.api.user.sources.routes import WikiPage
+
+ with app.test_request_context(
+ "/api/sources/x/wiki/page", method="PUT", json={}
+ ):
+ from flask import request
+ request.decoded_token = None
+ response = WikiPage().put("x")
+ assert response.status_code == 401
+
+ def test_owner_writes_and_enqueues_reembed(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ user = "u-edit-owner"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ) as mock_reembed, app.test_request_context(
+ f"/api/sources/{sid}/wiki/page",
+ method="PUT",
+ json={"path": "/notes.md", "content": "body"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().put(sid)
+
+ assert response.status_code == 200
+ assert response.json["page"]["path"] == "/notes.md"
+ assert response.json["page"]["version"] is not None
+ assert response.json["page"]["updated_via"] == "human"
+ page = WikiPagesRepository(pg_conn).get_by_path(sid, "/notes.md")
+ assert page["content"] == "body"
+ assert page["updated_via"] == "human"
+ mock_reembed.assert_called_once()
+ assert mock_reembed.call_args.args[1] == "/notes.md"
+ assert mock_reembed.call_args.kwargs["user"] == user
+
+ def test_team_editor_reembeds_as_owner(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ owner = "alice-edit"
+ editor = "bob-edit-editor"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ _grant_team_access(pg_conn, owner, editor, sid, "editor")
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ) as mock_reembed, app.test_request_context(
+ f"/api/sources/{sid}/wiki/page",
+ method="PUT",
+ json={"path": "/e.md", "content": "edited"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": editor}
+ response = WikiPage().put(sid)
+
+ assert response.status_code == 200
+ # Re-embed runs AS the owner so the owner-scoped worker load resolves.
+ assert mock_reembed.call_args.kwargs["user"] == owner
+
+ def test_stale_version_returns_409(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+ from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+ user = "u-edit-conflict"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ WikiPagesRepository(pg_conn).upsert(sid, "/c.md", "v1", updated_by=user)
+ # Bump to version 2 so a stale expected_version=1 loses the race.
+ WikiPagesRepository(pg_conn).upsert(sid, "/c.md", "v2", updated_by=user)
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ) as mock_reembed, app.test_request_context(
+ f"/api/sources/{sid}/wiki/page",
+ method="PUT",
+ json={"path": "/c.md", "content": "v3", "expected_version": 1},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().put(sid)
+
+ assert response.status_code == 409
+ mock_reembed.assert_not_called()
+
+ def test_traversal_path_returns_400(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ user = "u-edit-traversal"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=user, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ), app.test_request_context(
+ f"/api/sources/{sid}/wiki/page",
+ method="PUT",
+ json={"path": "/../secret.md", "content": "x"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": user}
+ response = WikiPage().put(sid)
+ assert response.status_code == 400
+
+ def test_viewer_rejected_403(self, app, pg_conn):
+ from application.api.user.sources.routes import WikiPage
+ from application.storage.db.repositories.sources import SourcesRepository
+
+ owner = "alice-edit-viewer"
+ viewer = "bob-edit-viewer"
+ src = SourcesRepository(pg_conn).create(
+ "wiki", user_id=owner, type="wiki", config={"kind": "wiki"}
+ )
+ sid = str(src["id"])
+ _grant_team_access(pg_conn, owner, viewer, sid, "viewer")
+
+ with _patch_db(pg_conn), patch(
+ "application.api.user.sources.routes.reembed_wiki_page.delay"
+ ) as mock_reembed, app.test_request_context(
+ f"/api/sources/{sid}/wiki/page",
+ method="PUT",
+ json={"path": "/v.md", "content": "x"},
+ ):
+ from flask import request
+ request.decoded_token = {"sub": viewer}
+ response = WikiPage().put(sid)
+
+ assert response.status_code == 403
+ mock_reembed.assert_not_called()
diff --git a/tests/api/user/test_tasks.py b/tests/api/user/test_tasks.py
index a27e5856..b38c065a 100644
--- a/tests/api/user/test_tasks.py
+++ b/tests/api/user/test_tasks.py
@@ -86,6 +86,20 @@ class TestReingestSourceTask:
assert result == {"status": "ok"}
+class TestConvertSourceToWikiTask:
+ @pytest.mark.unit
+ @patch("application.worker.convert_source_to_wiki_worker")
+ def test_calls_convert_worker(self, mock_worker):
+ from application.api.user.tasks import convert_source_to_wiki
+
+ mock_worker.return_value = {"status": "converted"}
+
+ result = convert_source_to_wiki("source123", "user1")
+
+ mock_worker.assert_called_once_with(ANY, "source123", "user1")
+ assert result == {"status": "converted"}
+
+
class TestScheduleSyncsTask:
@pytest.mark.unit
@patch("application.api.user.tasks.sync_worker")
@@ -286,6 +300,8 @@ class TestDurableTaskRetryPolicy:
"store_attachment",
"process_agent_webhook",
"ingest_connector_task",
+ "reembed_wiki_page",
+ "convert_source_to_wiki",
],
)
def test_task_has_retry_config(self, task_name):
diff --git a/tests/storage/db/repositories/test_wiki_pages.py b/tests/storage/db/repositories/test_wiki_pages.py
new file mode 100644
index 00000000..b521eb83
--- /dev/null
+++ b/tests/storage/db/repositories/test_wiki_pages.py
@@ -0,0 +1,309 @@
+"""Tests for WikiPagesRepository against a real Postgres instance.
+
+Wiki pages have a FK to ``sources`` (ON DELETE CASCADE), so each test creates a
+source row first.
+"""
+
+from __future__ import annotations
+
+import pytest
+
+from application.storage.db.repositories.sources import SourcesRepository
+from application.storage.db.repositories.wiki_pages import (
+ WikiPageConflict,
+ WikiPagesRepository,
+ build_wiki_directory_structure,
+ rebuild_wiki_directory_structure,
+)
+
+
+def _repo(conn) -> WikiPagesRepository:
+ return WikiPagesRepository(conn)
+
+
+def _make_source(conn, user_id: str = "wiki-user", name: str = "wiki-src") -> str:
+ """Insert a sources row and return its UUID as a string."""
+ return str(SourcesRepository(conn).create(name, user_id=user_id, type="wiki")["id"])
+
+
+class TestUpsert:
+ def test_creates_page(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ page = repo.upsert(sid, "/docs/readme.md", "Hello world", title="Readme")
+ assert page["path"] == "/docs/readme.md"
+ assert page["content"] == "Hello world"
+ assert page["title"] == "Readme"
+ assert page["version"] == 1
+ assert page["embed_status"] == "pending"
+ assert page["content_hash"] is not None
+ assert page["token_count"] is not None and page["token_count"] > 0
+
+ def test_get_by_path_after_upsert(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "content")
+ got = repo.get_by_path(sid, "/a.md")
+ assert got is not None
+ assert got["content"] == "content"
+
+ def test_records_updated_by(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ page = repo.upsert(sid, "/a.md", "x", updated_by="agent-1")
+ assert page["updated_by"] == "agent-1"
+
+ def test_records_updated_via(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ page = repo.upsert(sid, "/a.md", "x", updated_via="human")
+ assert page["updated_via"] == "human"
+
+ def test_updated_via_overwritten_on_change(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "v1", updated_via="agent")
+ page = repo.upsert(sid, "/a.md", "v2", updated_via="human")
+ assert page["updated_via"] == "human"
+
+ def test_updated_via_unchanged_on_identical_content(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "same", updated_via="agent")
+ again = repo.upsert(sid, "/a.md", "same", updated_via="human")
+ assert again["updated_via"] == "agent"
+
+
+class TestContentHashShortCircuit:
+ def test_identical_content_does_not_bump_version(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ first = repo.upsert(sid, "/a.md", "same")
+ second = repo.upsert(sid, "/a.md", "same")
+ assert first["id"] == second["id"]
+ assert second["version"] == 1
+ assert first["content_hash"] == second["content_hash"]
+
+ def test_identical_content_resets_status_unchanged(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "same")
+ repo.set_embed_status(sid, "/a.md", "embedded")
+ # Re-upsert with identical content must not reset embed_status to pending.
+ again = repo.upsert(sid, "/a.md", "same")
+ assert again["embed_status"] == "embedded"
+
+
+class TestVersionBump:
+ def test_changed_content_bumps_version_and_resets_status(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ v1 = repo.upsert(sid, "/a.md", "v1")
+ repo.set_embed_status(sid, "/a.md", "embedded")
+ v2 = repo.upsert(sid, "/a.md", "v2")
+ assert v2["version"] == 2
+ assert v2["content"] == "v2"
+ assert v2["embed_status"] == "pending"
+ assert v2["content_hash"] != v1["content_hash"]
+
+
+class TestListByPrefix:
+ def test_lists_matching_prefix(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/docs/a.md", "a")
+ repo.upsert(sid, "/docs/b.md", "b")
+ repo.upsert(sid, "/other/c.md", "c")
+ results = repo.list_by_prefix(sid, "/docs/")
+ assert {r["path"] for r in results} == {"/docs/a.md", "/docs/b.md"}
+
+ def test_prefix_underscore_is_literal_not_wildcard(self, pg_conn):
+ # ``_`` is a LIKE single-char wildcard; without escaping, listing
+ # ``/api_v1/`` would also match the sibling ``/apiXv1/``.
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/api_v1/a.md", "a")
+ repo.upsert(sid, "/apiXv1/b.md", "b")
+ results = repo.list_by_prefix(sid, "/api_v1/")
+ assert {r["path"] for r in results} == {"/api_v1/a.md"}
+
+ def test_list_for_source_returns_all(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "a")
+ repo.upsert(sid, "/b.md", "b")
+ results = repo.list_for_source(sid)
+ assert {r["path"] for r in results} == {"/a.md", "/b.md"}
+
+ def test_list_is_scoped_to_source(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid1 = _make_source(pg_conn, name="src-1")
+ sid2 = _make_source(pg_conn, name="src-2")
+ repo.upsert(sid1, "/a.md", "a")
+ repo.upsert(sid2, "/a.md", "b")
+ assert len(repo.list_for_source(sid1)) == 1
+ assert repo.get_by_path(sid1, "/a.md")["content"] == "a"
+ assert repo.get_by_path(sid2, "/a.md")["content"] == "b"
+
+
+class TestUpdatePath:
+ def test_renames_page(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/old.md", "content")
+ moved = repo.update_path(sid, "/old.md", "/new.md")
+ assert moved is True
+ assert repo.get_by_path(sid, "/old.md") is None
+ assert repo.get_by_path(sid, "/new.md")["content"] == "content"
+
+ def test_rename_nonexistent_returns_false(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ assert repo.update_path(sid, "/nope.md", "/new.md") is False
+
+ def test_rename_rejects_existing_target(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "a")
+ repo.upsert(sid, "/b.md", "b")
+ assert repo.update_path(sid, "/a.md", "/b.md") is False
+ # Both pages survive untouched.
+ assert repo.get_by_path(sid, "/a.md")["content"] == "a"
+ assert repo.get_by_path(sid, "/b.md")["content"] == "b"
+
+
+class TestDelete:
+ def test_delete_by_path(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/x.md", "c")
+ assert repo.delete_by_path(sid, "/x.md") == 1
+ assert repo.get_by_path(sid, "/x.md") is None
+
+ def test_delete_by_path_nonexistent_returns_zero(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ assert repo.delete_by_path(sid, "/nope.md") == 0
+
+ def test_delete_by_prefix(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/dir/a.md", "a")
+ repo.upsert(sid, "/dir/b.md", "b")
+ repo.upsert(sid, "/other/c.md", "c")
+ assert repo.delete_by_prefix(sid, "/dir/") == 2
+ assert repo.get_by_path(sid, "/other/c.md") is not None
+
+ def test_delete_by_prefix_underscore_is_literal_not_wildcard(self, pg_conn):
+ # Deleting ``/api_v1/`` must not also drop the sibling ``/apiXv1/``
+ # via the unescaped ``_`` LIKE wildcard.
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/api_v1/a.md", "a")
+ repo.upsert(sid, "/apiXv1/b.md", "b")
+ assert repo.delete_by_prefix(sid, "/api_v1/") == 1
+ assert repo.get_by_path(sid, "/apiXv1/b.md") is not None
+
+
+class TestExpectedVersion:
+ def test_matching_version_updates(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ v1 = repo.upsert(sid, "/a.md", "v1")
+ v2 = repo.upsert(sid, "/a.md", "v2", expected_version=v1["version"])
+ assert v2["version"] == 2
+ assert v2["content"] == "v2"
+
+ def test_stale_version_raises_conflict(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ v1 = repo.upsert(sid, "/a.md", "v1")
+ # A concurrent writer bumps the version out from under us.
+ repo.upsert(sid, "/a.md", "v2")
+ with pytest.raises(WikiPageConflict):
+ repo.upsert(sid, "/a.md", "v3", expected_version=v1["version"])
+ # The conflicting write left the concurrent value intact.
+ assert repo.get_by_path(sid, "/a.md")["content"] == "v2"
+
+ def test_expected_version_ignored_on_new_page(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ page = repo.upsert(sid, "/new.md", "content", expected_version=99)
+ assert page["version"] == 1
+
+
+class TestDirectoryStructure:
+ def test_build_tree_shape(self):
+ pages = [
+ {"path": "/docs/setup.md", "content": "hello", "token_count": 3},
+ {"path": "/readme.md", "content": "x", "token_count": 1},
+ ]
+ tree = build_wiki_directory_structure(pages)
+ assert "readme.md" in tree
+ assert tree["docs"]["setup.md"]["type"] == "text/markdown"
+ assert tree["docs"]["setup.md"]["token_count"] == 3
+ assert tree["readme.md"]["size_bytes"] == 1
+
+ def test_rebuild_writes_to_source(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = str(
+ SourcesRepository(pg_conn).create(
+ "rebuild-src", user_id="rebuild-user", type="wiki"
+ )["id"]
+ )
+ repo.upsert(sid, "/a.md", "a")
+ repo.upsert(sid, "/dir/b.md", "b")
+ tree = rebuild_wiki_directory_structure(pg_conn, sid, "rebuild-user")
+ assert "a.md" in tree
+ assert "b.md" in tree["dir"]
+ stored = SourcesRepository(pg_conn).get(sid, "rebuild-user")
+ assert "a.md" in (stored["directory_structure"] or {})
+
+ def test_rebuild_sets_source_tokens_to_sum(self, pg_conn):
+ repo = _repo(pg_conn)
+ owner = "rebuild-tokens-user"
+ sid = str(
+ SourcesRepository(pg_conn).create(
+ "rebuild-tokens-src", user_id=owner, type="wiki"
+ )["id"]
+ )
+ page_a = repo.upsert(sid, "/a.md", "alpha beta gamma")
+ page_b = repo.upsert(sid, "/dir/b.md", "delta epsilon")
+ expected = page_a["token_count"] + page_b["token_count"]
+
+ rebuild_wiki_directory_structure(pg_conn, sid, owner)
+
+ stored = SourcesRepository(pg_conn).get(sid, owner)
+ assert int(stored["tokens"]) == expected
+
+ def test_rebuild_after_delete_lowers_source_tokens(self, pg_conn):
+ repo = _repo(pg_conn)
+ owner = "rebuild-delete-user"
+ sid = str(
+ SourcesRepository(pg_conn).create(
+ "rebuild-delete-src", user_id=owner, type="wiki"
+ )["id"]
+ )
+ page_a = repo.upsert(sid, "/a.md", "alpha beta gamma")
+ repo.upsert(sid, "/b.md", "delta epsilon")
+ rebuild_wiki_directory_structure(pg_conn, sid, owner)
+
+ repo.delete_by_path(sid, "/b.md")
+ rebuild_wiki_directory_structure(pg_conn, sid, owner)
+
+ stored = SourcesRepository(pg_conn).get(sid, owner)
+ assert int(stored["tokens"]) == page_a["token_count"]
+
+
+class TestSetEmbedStatus:
+ def test_set_embed_status(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ repo.upsert(sid, "/a.md", "a")
+ assert repo.set_embed_status(sid, "/a.md", "embedded") is True
+ assert repo.get_by_path(sid, "/a.md")["embed_status"] == "embedded"
+
+ def test_set_embed_status_nonexistent_returns_false(self, pg_conn):
+ repo = _repo(pg_conn)
+ sid = _make_source(pg_conn)
+ assert repo.set_embed_status(sid, "/nope.md", "embedded") is False
diff --git a/tests/storage/db/test_migration_0023.py b/tests/storage/db/test_migration_0023.py
new file mode 100644
index 00000000..6ac827ef
--- /dev/null
+++ b/tests/storage/db/test_migration_0023.py
@@ -0,0 +1,77 @@
+"""Migration round-trip test for 0023_wiki_pages."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+
+
+pytestmark = pytest.mark.integration
+
+
+def _alembic_ini() -> Path:
+ return Path(__file__).resolve().parents[3] / "application" / "alembic.ini"
+
+
+def _run_alembic(url: str, *args: str) -> None:
+ subprocess.check_call(
+ [sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
+ timeout=60,
+ env={**os.environ, "POSTGRES_URI": url},
+ )
+
+
+def _alembic_heads(url: str) -> list[str]:
+ out = subprocess.check_output(
+ [sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), "heads"],
+ timeout=60,
+ env={**os.environ, "POSTGRES_URI": url},
+ text=True,
+ )
+ return [line for line in out.splitlines() if line.strip()]
+
+
+def _alembic_version(conn) -> str:
+ return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
+
+
+def _table_exists(conn, table: str) -> bool:
+ row = conn.execute(
+ text(
+ "SELECT 1 FROM information_schema.tables "
+ "WHERE table_name = :t AND table_schema = 'public'"
+ ),
+ {"t": table},
+ ).fetchone()
+ return row is not None
+
+
+_0023 = "0023_wiki_pages"
+_0022 = "0022_source_config"
+
+
+class TestMigration0023RoundTrip:
+ def test_single_head(self, pg_engine):
+ url = pg_engine.url.render_as_string(hide_password=False)
+ assert len(_alembic_heads(url)) == 1
+
+ def test_head_has_wiki_pages_table(self, pg_engine):
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) >= _0023
+ assert _table_exists(conn, "wiki_pages")
+
+ def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
+ url = pg_engine.url.render_as_string(hide_password=False)
+ _run_alembic(url, "downgrade", _0022)
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) == _0022
+ assert not _table_exists(conn, "wiki_pages")
+ _run_alembic(url, "upgrade", "head")
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) >= _0023
+ assert _table_exists(conn, "wiki_pages")
diff --git a/tests/storage/db/test_migration_0024.py b/tests/storage/db/test_migration_0024.py
new file mode 100644
index 00000000..9c215ccd
--- /dev/null
+++ b/tests/storage/db/test_migration_0024.py
@@ -0,0 +1,77 @@
+"""Migration round-trip test for 0024_wiki_pages_updated_via."""
+
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from sqlalchemy import text
+
+
+pytestmark = pytest.mark.integration
+
+
+def _alembic_ini() -> Path:
+ return Path(__file__).resolve().parents[3] / "application" / "alembic.ini"
+
+
+def _run_alembic(url: str, *args: str) -> None:
+ subprocess.check_call(
+ [sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), *args],
+ timeout=60,
+ env={**os.environ, "POSTGRES_URI": url},
+ )
+
+
+def _alembic_heads(url: str) -> list[str]:
+ out = subprocess.check_output(
+ [sys.executable, "-m", "alembic", "-c", str(_alembic_ini()), "heads"],
+ timeout=60,
+ env={**os.environ, "POSTGRES_URI": url},
+ text=True,
+ )
+ return [line for line in out.splitlines() if line.strip()]
+
+
+def _alembic_version(conn) -> str:
+ return conn.execute(text("SELECT version_num FROM alembic_version")).scalar()
+
+
+def _column_exists(conn, table: str, column: str) -> bool:
+ row = conn.execute(
+ text(
+ "SELECT 1 FROM information_schema.columns "
+ "WHERE table_name = :t AND column_name = :c AND table_schema = 'public'"
+ ),
+ {"t": table, "c": column},
+ ).fetchone()
+ return row is not None
+
+
+_0024 = "0024_wiki_pages_updated_via"
+_0023 = "0023_wiki_pages"
+
+
+class TestMigration0024RoundTrip:
+ def test_single_head(self, pg_engine):
+ url = pg_engine.url.render_as_string(hide_password=False)
+ assert len(_alembic_heads(url)) == 1
+
+ def test_head_has_updated_via_column(self, pg_engine):
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) >= _0024
+ assert _column_exists(conn, "wiki_pages", "updated_via")
+
+ def test_downgrade_drops_then_upgrade_restores(self, pg_engine):
+ url = pg_engine.url.render_as_string(hide_password=False)
+ _run_alembic(url, "downgrade", _0023)
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) == _0023
+ assert not _column_exists(conn, "wiki_pages", "updated_via")
+ _run_alembic(url, "upgrade", "head")
+ with pg_engine.connect() as conn:
+ assert _alembic_version(conn) >= _0024
+ assert _column_exists(conn, "wiki_pages", "updated_via")
diff --git a/tests/vectorstore/test_pgvector.py b/tests/vectorstore/test_pgvector.py
index 5d87c09b..78db6de2 100644
--- a/tests/vectorstore/test_pgvector.py
+++ b/tests/vectorstore/test_pgvector.py
@@ -331,6 +331,41 @@ class TestPGVectorStoreDeleteChunk:
assert result is False
+@pytest.mark.unit
+class TestPGVectorStoreDeleteChunksBySourcePath:
+ def test_targeted_delete_is_parameterized(self):
+ store, mock_conn, mock_cursor, _ = _make_store(source_id="src1")
+ mock_cursor.rowcount = 3
+
+ deleted = store.delete_chunks_by_source_path("/docs/page.md")
+
+ assert deleted == 3
+ sql, params = mock_cursor.execute.call_args[0]
+ # Single targeted DELETE; the path is a bound param, never interpolated.
+ assert "/docs/page.md" not in sql
+ assert "DELETE FROM" in sql
+ assert "metadata->>'source' = %s" in sql
+ assert "source_id = %s" in sql
+ assert params == ("src1", "/docs/page.md")
+ mock_conn.commit.assert_called_once()
+
+ def test_returns_zero_when_no_match(self):
+ store, mock_conn, mock_cursor, _ = _make_store(source_id="src1")
+ mock_cursor.rowcount = 0
+
+ assert store.delete_chunks_by_source_path("/missing.md") == 0
+ mock_conn.commit.assert_called_once()
+
+ def test_rolls_back_and_raises_on_error(self):
+ store, mock_conn, mock_cursor, _ = _make_store()
+ mock_cursor.execute.side_effect = Exception("delete failed")
+
+ with pytest.raises(Exception, match="delete failed"):
+ store.delete_chunks_by_source_path("/x.md")
+
+ mock_conn.rollback.assert_called_once()
+
+
@pytest.mark.unit
class TestPGVectorStoreConnection:
def test_get_connection_creates_new_when_closed(self):
diff --git a/tests/worker/test_convert_source_to_wiki.py b/tests/worker/test_convert_source_to_wiki.py
new file mode 100644
index 00000000..fa637799
--- /dev/null
+++ b/tests/worker/test_convert_source_to_wiki.py
@@ -0,0 +1,440 @@
+"""Tests for ``application.worker.convert_source_to_wiki_worker``.
+
+The worker reassembles wiki pages from a source's existing vector-store
+chunks (grouped by ``metadata.source``) and enqueues a per-page re-embed.
+``VectorCreator.create_vectorstore`` and the re-embed task are mocked so no
+real store access or embeddings run; the ``sources`` / ``wiki_pages`` rows
+are real so the assertions read them back.
+"""
+
+from __future__ import annotations
+
+from unittest.mock import MagicMock
+
+import pytest
+
+from application.storage.db.repositories.sources import SourcesRepository
+from application.storage.db.repositories.wiki_pages import WikiPagesRepository
+
+
+def _seed_source(pg_conn, config=None, file_path=""):
+ src = SourcesRepository(pg_conn).create(
+ "doc-set",
+ user_id="alice",
+ type="crawler",
+ retriever="classic",
+ file_path=file_path,
+ config=config or {},
+ directory_structure={"a.md": {"type": "text/markdown", "size_bytes": 1}},
+ )
+ return str(src["id"])
+
+
+def _chunk(text, source=None, doc_id="d", **extra_meta):
+ metadata = dict(extra_meta)
+ if source is not None:
+ metadata["source"] = source
+ return {"doc_id": doc_id, "text": text, "metadata": metadata}
+
+
+def _patch_store(monkeypatch, chunks):
+ store = MagicMock(name="vectorstore")
+ store.get_chunks.return_value = chunks
+ monkeypatch.setattr(
+ "application.vectorstore.vector_creator.VectorCreator.create_vectorstore",
+ lambda *a, **kw: store,
+ )
+ return store
+
+
+def _patch_reembed(monkeypatch):
+ delay = MagicMock(name="reembed_delay")
+ monkeypatch.setattr("application.api.user.tasks.reembed_wiki_page.delay", delay)
+ return delay
+
+
+@pytest.mark.unit
+class TestConvertSourceToWikiWorker:
+ def test_two_pages_reassembled_from_chunks(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("guide part one.", source="guide.md", doc_id="g1"),
+ _chunk("guide part two.", source="guide.md", doc_id="g2"),
+ _chunk("intro body.", source="notes/intro.md", doc_id="i1"),
+ ],
+ )
+ delay = _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "converted"
+ assert result["pages_created"] == 2
+
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ by_path = {p["path"]: p for p in pages}
+ assert set(by_path) == {"/guide.md", "/notes/intro.md"}
+ assert by_path["/guide.md"]["content"] == "guide part one.\n\nguide part two."
+ assert by_path["/guide.md"]["title"] == "guide.md"
+ assert by_path["/notes/intro.md"]["content"] == "intro body."
+ assert by_path["/notes/intro.md"]["title"] == "intro.md"
+
+ assert delay.call_count == 2
+ paths = {c.args[1] for c in delay.call_args_list}
+ assert paths == {"/guide.md", "/notes/intro.md"}
+ for call in delay.call_args_list:
+ assert call.args[0] == source_id
+ assert call.kwargs["user"] == "alice"
+ assert call.kwargs["idempotency_key"].startswith(
+ f"reembed-wiki:{source_id}:"
+ )
+
+ def test_original_chunks_deleted_after_convert(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ store = _patch_store(
+ monkeypatch,
+ [
+ _chunk("guide part one.", source="guide.md", doc_id="g1"),
+ _chunk("guide part two.", source="guide.md", doc_id="g2"),
+ _chunk("intro body.", source="notes/intro.md", doc_id="i1"),
+ ],
+ )
+ delay = _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "converted"
+ deleted = {c.args[0] for c in store.delete_chunk.call_args_list}
+ assert deleted == {"g1", "g2", "i1"}
+ assert delay.call_count == 2
+
+ def test_chunk_without_doc_id_not_deleted(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ chunk = {"text": "body", "metadata": {"source": "a.md"}}
+ store = _patch_store(monkeypatch, [chunk])
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "converted"
+ store.delete_chunk.assert_not_called()
+
+ def test_skipped_chunk_with_doc_id_still_deleted(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ # A chunk skipped for an invalid path must still have its original
+ # vector chunk purged, not left orphaned after the source flips to wiki.
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ store = _patch_store(
+ monkeypatch,
+ [
+ _chunk("good", source="ok.md", doc_id="ok"),
+ _chunk("bad", source="../evil.md", doc_id="evil"),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["pages_created"] == 1
+ deleted = {c.args[0] for c in store.delete_chunk.call_args_list}
+ assert deleted == {"ok", "evil"}
+
+ def test_no_pages_path_deletes_nothing(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ store = _patch_store(monkeypatch, [])
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "no_pages"
+ store.delete_chunk.assert_not_called()
+
+ def test_kind_flipped_and_exposure_defaulted(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+ from application.storage.db.source_config import SourceConfig
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(monkeypatch, [_chunk("body", source="a.md")])
+ _patch_reembed(monkeypatch)
+
+ worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
+ cfg = SourceConfig.parse(refreshed.get("config"))
+ assert cfg.kind == "wiki"
+ assert cfg.retrieval.exposure == "agentic_tool"
+
+ def test_preserves_non_default_exposure(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+ from application.storage.db.source_config import SourceConfig
+
+ source_id = _seed_source(
+ pg_conn, config={"retrieval": {"exposure": "agentic_tool"}}
+ )
+ _patch_store(monkeypatch, [_chunk("body", source="a.md")])
+ _patch_reembed(monkeypatch)
+
+ worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
+ cfg = SourceConfig.parse(refreshed.get("config"))
+ assert cfg.kind == "wiki"
+ assert cfg.retrieval.exposure == "agentic_tool"
+
+ def test_chunk_order_hint_respected(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("second", source="a.md", doc_id="a2", chunk_index=1),
+ _chunk("first", source="a.md", doc_id="a1", chunk_index=0),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ by_path = {p["path"]: p for p in pages}
+ assert by_path["/a.md"]["content"] == "first\n\nsecond"
+
+ def test_short_overlap_not_trimmed(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("The end.", source="a.md", doc_id="a1"),
+ _chunk("The end. More", source="a.md", doc_id="a2"),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ by_path = {p["path"]: p for p in pages}
+ assert by_path["/a.md"]["content"] == "The end.\n\nThe end. More"
+
+ def test_long_overlap_trimmed_between_chunks(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ overlap = "x" * 40
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("head " + overlap, source="a.md", doc_id="a1"),
+ _chunk(overlap + " tail", source="a.md", doc_id="a2"),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ by_path = {p["path"]: p for p in pages}
+ assert by_path["/a.md"]["content"] == "head " + overlap + "\n\n tail"
+
+ def test_no_chunks_leaves_kind_and_structure(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+ from application.storage.db.source_config import SourceConfig
+
+ original_structure = {"a.md": {"type": "text/markdown", "size_bytes": 1}}
+ source_id = _seed_source(pg_conn)
+ _patch_store(monkeypatch, [])
+ rebuild = MagicMock(name="rebuild")
+ monkeypatch.setattr(worker, "rebuild_wiki_directory_structure", rebuild)
+ delay = _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "no_pages"
+ assert result["pages_created"] == 0
+ rebuild.assert_not_called()
+ delay.assert_not_called()
+
+ refreshed = SourcesRepository(pg_conn).get_any(source_id, "alice")
+ assert SourceConfig.parse(refreshed.get("config")).kind != "wiki"
+ assert refreshed.get("directory_structure") == original_structure
+
+ def test_missing_path_chunk_skipped(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("good", source="ok.md"),
+ _chunk("orphan", source=None),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["pages_created"] == 1
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ assert {p["path"] for p in pages} == {"/ok.md"}
+ assert any(s["reason"] == "missing path" for s in result["skipped"])
+
+ def test_invalid_path_chunk_skipped(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("good", source="ok.md"),
+ _chunk("bad", source="../evil.md"),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["pages_created"] == 1
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ assert {p["path"] for p in pages} == {"/ok.md"}
+ skipped = {(s["file"], s["reason"]) for s in result["skipped"]}
+ assert ("../evil.md", "invalid path") in skipped
+
+ def test_path_falls_back_to_filename_then_title(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("from filename", source=None, filename="byname.md"),
+ _chunk("from title", source=None, title="bytitle.md"),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["pages_created"] == 2
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ assert {p["path"] for p in pages} == {"/byname.md", "/bytitle.md"}
+
+ def test_crawler_chunk_uses_file_path_not_url(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk(
+ "setup body",
+ source="https://docs.x.com/guides/setup",
+ file_path="guides/setup.md",
+ ),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "converted"
+ assert result["skipped"] == []
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ assert {p["path"] for p in pages} == {"/guides/setup.md"}
+
+ def test_connector_chunks_kept_separate_by_file_name(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [
+ _chunk("page a", source="confluence", file_name="Page A", doc_id="a"),
+ _chunk("page b", source="confluence", file_name="Page B", doc_id="b"),
+ ],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["pages_created"] == 2
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ assert {p["path"] for p in pages} == {"/Page A", "/Page B"}
+
+ def test_url_only_chunk_normalized_not_skipped(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+ _patch_store(
+ monkeypatch,
+ [_chunk("body", source="https://x.com/a/b")],
+ )
+ _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "converted"
+ assert result["skipped"] == []
+ pages = WikiPagesRepository(pg_conn).list_for_source(source_id)
+ assert {p["path"] for p in pages} == {"/a/b"}
+
+ def test_already_wiki_returns_early(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn, config={"kind": "wiki"})
+ store = _patch_store(monkeypatch, [_chunk("body", source="a.md")])
+ delay = _patch_reembed(monkeypatch)
+
+ result = worker.convert_source_to_wiki_worker(task_self, source_id, "alice")
+
+ assert result["status"] == "already_wiki"
+ store.get_chunks.assert_not_called()
+ delay.assert_not_called()
diff --git a/tests/worker/test_reembed_wiki_page.py b/tests/worker/test_reembed_wiki_page.py
new file mode 100644
index 00000000..36027734
--- /dev/null
+++ b/tests/worker/test_reembed_wiki_page.py
@@ -0,0 +1,183 @@
+"""Tests for the per-page wiki re-embed worker and Celery task.
+
+The worker loads the source row from the ephemeral DB, then re-embeds (or
+purges) one wiki page. The vector store, ``ChunkerCreator``, and the page
+lookup are mocked so no live embeddings run; the ``sources`` row is real so
+``SourcesRepository.get_any`` resolves.
+"""
+
+from __future__ import annotations
+
+from contextlib import contextmanager
+from unittest.mock import MagicMock
+
+import pytest
+
+from application.parser.schema.base import Document
+from application.storage.db.repositories.sources import SourcesRepository
+
+
+def _seed_source(pg_conn) -> str:
+ src = SourcesRepository(pg_conn).create(
+ "wiki-set",
+ user_id="alice",
+ type="wiki",
+ config={"kind": "wiki"},
+ )
+ return str(src["id"])
+
+
+def _patch_store(monkeypatch, store):
+ monkeypatch.setattr(
+ "application.vectorstore.vector_creator.VectorCreator.create_vectorstore",
+ lambda *a, **kw: store,
+ )
+
+
+def _patch_repo(monkeypatch, page):
+ """Replace ``WikiPagesRepository`` with one returning ``page`` and a stub setter."""
+ repo = MagicMock(name="wiki_repo")
+ repo.get_by_path.return_value = page
+ repo.set_embed_status.return_value = True
+ monkeypatch.setattr(
+ "application.worker.WikiPagesRepository", lambda conn: repo
+ )
+ return repo
+
+
+def _patch_chunker(monkeypatch, chunks):
+ calls: list[dict] = []
+
+ def _create_chunker(strategy, **kwargs):
+ calls.append({"strategy": strategy, **kwargs})
+ chunker = MagicMock(name="chunker")
+ chunker.chunk.return_value = chunks
+ return chunker
+
+ monkeypatch.setattr(
+ "application.worker.ChunkerCreator.create_chunker",
+ staticmethod(_create_chunker),
+ )
+ return calls
+
+
+@pytest.mark.unit
+class TestReembedWikiPageWorker:
+ def test_page_exists_reembeds(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+
+ store = MagicMock(name="vector_store")
+ store.delete_chunks_by_source_path.return_value = 3
+ _patch_store(monkeypatch, store)
+
+ repo = _patch_repo(
+ monkeypatch,
+ {"content": "page body", "title": "Page Title"},
+ )
+ _patch_chunker(
+ monkeypatch,
+ [Document(text="c1"), Document(text="c2")],
+ )
+
+ result = worker.reembed_wiki_page_worker(
+ task_self, source_id, "guide/intro.md", "hash-1", "alice"
+ )
+
+ store.delete_chunks_by_source_path.assert_called_once_with(
+ "guide/intro.md"
+ )
+ assert store.add_chunk.call_count == 2
+ for call in store.add_chunk.call_args_list:
+ assert call.kwargs["metadata"]["source"] == "guide/intro.md"
+ assert call.kwargs["metadata"]["title"] == "Page Title"
+ assert call.kwargs["metadata"]["filename"] == "guide/intro.md"
+ repo.set_embed_status.assert_called_once_with(
+ source_id, "guide/intro.md", "embedded"
+ )
+ assert result == {"status": "embedded", "added": 2, "deleted": 3}
+
+ def test_page_missing_purges(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+
+ store = MagicMock(name="vector_store")
+ store.delete_chunks_by_source_path.return_value = 4
+ _patch_store(monkeypatch, store)
+
+ repo = _patch_repo(monkeypatch, None)
+
+ result = worker.reembed_wiki_page_worker(
+ task_self, source_id, "old/page.md", "hash-x", "alice"
+ )
+
+ store.delete_chunks_by_source_path.assert_called_once_with("old/page.md")
+ store.add_chunk.assert_not_called()
+ repo.set_embed_status.assert_not_called()
+ assert result == {"status": "deleted", "deleted": 4}
+
+ def test_embed_failure_sets_failed_and_reraises(
+ self, pg_conn, patch_worker_db, task_self, monkeypatch
+ ):
+ from application import worker
+
+ source_id = _seed_source(pg_conn)
+
+ store = MagicMock(name="vector_store")
+ store.delete_chunks_by_source_path.return_value = 0
+ store.add_chunk.side_effect = RuntimeError("embed boom")
+ _patch_store(monkeypatch, store)
+
+ repo = _patch_repo(
+ monkeypatch, {"content": "page body", "title": "T"}
+ )
+ _patch_chunker(monkeypatch, [Document(text="c1")])
+
+ with pytest.raises(RuntimeError, match="embed boom"):
+ worker.reembed_wiki_page_worker(
+ task_self, source_id, "p.md", "hash-2", "alice"
+ )
+
+ repo.set_embed_status.assert_called_once_with(source_id, "p.md", "failed")
+
+
+@pytest.mark.unit
+class TestReembedWikiPageTask:
+ def test_idempotency_key_is_content_hash(self, pg_conn, monkeypatch):
+ """A redelivery with the same content_hash key short-circuits the worker."""
+ from application.api.user import tasks
+
+ @contextmanager
+ def _yield():
+ yield pg_conn
+
+ monkeypatch.setattr(
+ "application.api.user.idempotency.db_session", _yield
+ )
+ monkeypatch.setattr(
+ "application.api.user.idempotency.db_readonly", _yield
+ )
+
+ calls: list[str] = []
+
+ def _fake_worker(self, source_id, path, content_hash, user):
+ calls.append(content_hash)
+ return {"status": "embedded", "added": 1, "deleted": 0}
+
+ monkeypatch.setattr(tasks, "reembed_wiki_page_worker", _fake_worker)
+
+ first = tasks.reembed_wiki_page(
+ "src-1", "p.md", "abc123", "alice", idempotency_key="abc123",
+ )
+ second = tasks.reembed_wiki_page(
+ "src-1", "p.md", "abc123", "alice", idempotency_key="abc123",
+ )
+
+ assert first == second
+ assert len(calls) == 1