mirror of
https://github.com/tiennm99/DocsGPT.git
synced 2026-10-14 04:15:35 +00:00
feat(wiki): human page editing + provenance stamps
Unit 6 of F-Wiki (D22 + D24). Migration 0024 adds wiki_pages.updated_via; set to 'agent' by WikiTool + convert, 'human' by the edit/seed endpoints (content-hash short-circuit preserves it). WikiViewer gains a markdown editor (Save via PUT with expected_version; 409 reloads latest while keeping the draft; 403 graceful) and a per-page provenance stamp (editor/when/version). Edit gated on write access client-side; backend remains the real authz.
This commit is contained in:
1 parent
0c9e0313bd
commit
919fe10ab9
16 files changed
+661
-68
No files matched your search
@@ -15,6 +15,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
WIKI_TOOL_ID = "wiki"
|
||||
|
||||
WIKI_UPDATED_VIA_AGENT = "agent"
|
||||
|
||||
MAX_WIKI_PAGE_BYTES = 1_000_000
|
||||
|
||||
|
||||
@@ -313,6 +315,7 @@ class WikiTool(Tool):
|
||||
validated_path,
|
||||
content,
|
||||
updated_by=self.updated_by,
|
||||
updated_via=WIKI_UPDATED_VIA_AGENT,
|
||||
expected_version=(
|
||||
existing.get("version") if existing else None
|
||||
),
|
||||
@@ -357,6 +360,7 @@ class WikiTool(Tool):
|
||||
updated,
|
||||
title=page.get("title"),
|
||||
updated_by=self.updated_by,
|
||||
updated_via=WIKI_UPDATED_VIA_AGENT,
|
||||
expected_version=page.get("version"),
|
||||
)
|
||||
except WikiPageConflict:
|
||||
@@ -395,6 +399,7 @@ class WikiTool(Tool):
|
||||
updated,
|
||||
title=page.get("title"),
|
||||
updated_by=self.updated_by,
|
||||
updated_via=WIKI_UPDATED_VIA_AGENT,
|
||||
expected_version=page.get("version"),
|
||||
)
|
||||
except WikiPageConflict:
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
"""0024 wiki pages updated_via — provenance of the last page write.
|
||||
|
||||
Adds a nullable ``updated_via`` column recording which channel last wrote a
|
||||
page ("agent" via the WikiTool/conversion, "human" via the edit endpoint) so the
|
||||
viewer can stamp each page's provenance alongside ``updated_by`` / ``updated_at``
|
||||
/ ``version``.
|
||||
|
||||
Revision ID: 0024_wiki_pages_updated_via
|
||||
Revises: 0023_wiki_pages
|
||||
"""
|
||||
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision: str = "0024_wiki_pages_updated_via"
|
||||
down_revision: Union[str, None] = "0023_wiki_pages"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.execute("ALTER TABLE wiki_pages ADD COLUMN IF NOT EXISTS updated_via TEXT;")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute("ALTER TABLE wiki_pages DROP COLUMN IF EXISTS updated_via;")
|
||||
@@ -697,6 +697,7 @@ def _wiki_page_node(page):
|
||||
"version": page.get("version"),
|
||||
"embed_status": page.get("embed_status"),
|
||||
"updated_by": page.get("updated_by"),
|
||||
"updated_via": page.get("updated_via"),
|
||||
"updated_at": (
|
||||
page["updated_at"].isoformat()
|
||||
if page.get("updated_at") is not None
|
||||
@@ -769,6 +770,7 @@ class CreateWikiSource(Resource):
|
||||
WIKI_INDEX_PATH,
|
||||
initial_content,
|
||||
updated_by=user,
|
||||
updated_via="human",
|
||||
)
|
||||
rebuild_wiki_directory_structure(conn, source_id, user)
|
||||
except Exception as err:
|
||||
@@ -922,6 +924,7 @@ class WikiPage(Resource):
|
||||
path,
|
||||
content,
|
||||
updated_by=user,
|
||||
updated_via="human",
|
||||
expected_version=expected_version,
|
||||
)
|
||||
except WikiPageConflict:
|
||||
|
||||
@@ -431,6 +431,7 @@ wiki_pages_table = Table(
|
||||
Column("content_hash", Text),
|
||||
Column("embed_status", Text, nullable=False, server_default="pending"),
|
||||
Column("updated_by", Text),
|
||||
Column("updated_via", Text),
|
||||
Column("created_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
Column("updated_at", DateTime(timezone=True), nullable=False, server_default=func.now()),
|
||||
UniqueConstraint("source_id", "path", name="wiki_pages_source_path_uidx"),
|
||||
|
||||
@@ -37,6 +37,7 @@ class WikiPagesRepository:
|
||||
content: str,
|
||||
title: Optional[str] = None,
|
||||
updated_by: Optional[str] = None,
|
||||
updated_via: Optional[str] = None,
|
||||
expected_version: Optional[int] = None,
|
||||
) -> dict:
|
||||
"""Create or overwrite a page.
|
||||
@@ -67,6 +68,7 @@ class WikiPagesRepository:
|
||||
token_count = :token_count,
|
||||
content_hash = :content_hash,
|
||||
updated_by = :updated_by,
|
||||
updated_via = :updated_via,
|
||||
version = version + 1,
|
||||
embed_status = 'pending',
|
||||
updated_at = now()
|
||||
@@ -84,6 +86,7 @@ class WikiPagesRepository:
|
||||
"token_count": num_tokens_from_string(content),
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
"expected_version": expected_version,
|
||||
},
|
||||
)
|
||||
@@ -100,10 +103,10 @@ class WikiPagesRepository:
|
||||
"""
|
||||
INSERT INTO wiki_pages
|
||||
(source_id, path, title, content, token_count,
|
||||
content_hash, updated_by, embed_status)
|
||||
content_hash, updated_by, updated_via, embed_status)
|
||||
VALUES
|
||||
(CAST(:source_id AS uuid), :path, :title, :content, :token_count,
|
||||
:content_hash, :updated_by, 'pending')
|
||||
:content_hash, :updated_by, :updated_via, 'pending')
|
||||
ON CONFLICT (source_id, path)
|
||||
DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
@@ -111,6 +114,7 @@ class WikiPagesRepository:
|
||||
token_count = EXCLUDED.token_count,
|
||||
content_hash = EXCLUDED.content_hash,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_via = EXCLUDED.updated_via,
|
||||
version = wiki_pages.version + 1,
|
||||
embed_status = 'pending',
|
||||
updated_at = now()
|
||||
@@ -125,6 +129,7 @@ class WikiPagesRepository:
|
||||
"token_count": num_tokens_from_string(content),
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
},
|
||||
)
|
||||
return row_to_dict(result.fetchone())
|
||||
|
||||
@@ -2158,7 +2158,12 @@ def convert_source_to_wiki_worker(self, source_id, user):
|
||||
rel_path
|
||||
)
|
||||
repo.upsert(
|
||||
source_id, page_path, content, title=title, updated_by=user
|
||||
source_id,
|
||||
page_path,
|
||||
content,
|
||||
title=title,
|
||||
updated_by=user,
|
||||
updated_via="agent",
|
||||
)
|
||||
created_pages.append((page_path, _content_hash(content)))
|
||||
|
||||
|
||||
@@ -115,6 +115,12 @@ const userService = {
|
||||
token: string | null,
|
||||
): Promise<Response> =>
|
||||
throttledApiClient.get(endpoints.USER.WIKI_PAGE(sourceId, path), token),
|
||||
updateWikiPage: (
|
||||
sourceId: string,
|
||||
data: { path: string; content: string; expected_version?: number },
|
||||
token: string | null,
|
||||
): Promise<Response> =>
|
||||
apiClient.put(endpoints.USER.WIKI_PAGE(sourceId, data.path), data, token),
|
||||
getAvailableTools: (token: string | null): Promise<any> =>
|
||||
apiClient.get(endpoints.USER.GET_AVAILABLE_TOOLS, token),
|
||||
getUserTools: (token: string | null): Promise<any> =>
|
||||
|
||||
@@ -7,36 +7,83 @@ import remarkGfm from 'remark-gfm';
|
||||
import userService from '../api/services/userService';
|
||||
import ArrowLeft from '../assets/arrow-left.svg';
|
||||
import { selectToken } from '../preferences/preferenceSlice';
|
||||
import { decodeJwtPayload } from '../utils/jwtUtils';
|
||||
import { Button } from './ui/button';
|
||||
import SkeletonLoader from './SkeletonLoader';
|
||||
|
||||
interface WikiPageNode {
|
||||
path: string;
|
||||
title?: string | null;
|
||||
token_count?: number;
|
||||
embed_status?: string;
|
||||
}
|
||||
import {
|
||||
WikiPageNode,
|
||||
formatRelativeTime,
|
||||
provenanceKey,
|
||||
saveWikiPage,
|
||||
} from './wikiViewerUtils';
|
||||
|
||||
interface WikiViewerProps {
|
||||
docId: string;
|
||||
sourceName: string;
|
||||
canEdit?: boolean;
|
||||
onBackToDocuments: () => void;
|
||||
}
|
||||
|
||||
const markdownComponents = {
|
||||
h1: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h1 className="mt-4 mb-2 text-xl font-semibold">{children}</h1>
|
||||
),
|
||||
h2: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h2 className="mt-4 mb-2 text-lg font-semibold">{children}</h2>
|
||||
),
|
||||
h3: ({ children }: { children?: React.ReactNode }) => (
|
||||
<h3 className="mt-3 mb-2 text-base font-semibold">{children}</h3>
|
||||
),
|
||||
p: ({ children }: { children?: React.ReactNode }) => (
|
||||
<p className="mb-3">{children}</p>
|
||||
),
|
||||
ul: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ul className="mb-3 list-inside list-disc pl-4">{children}</ul>
|
||||
),
|
||||
ol: ({ children }: { children?: React.ReactNode }) => (
|
||||
<ol className="mb-3 list-inside list-decimal pl-4">{children}</ol>
|
||||
),
|
||||
a: ({ children, href }: { children?: React.ReactNode; href?: string }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }: { children?: React.ReactNode }) => (
|
||||
<code className="dark:bg-accent rounded-md bg-gray-200 px-1.5 py-0.5 text-xs">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
};
|
||||
|
||||
const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
docId,
|
||||
sourceName,
|
||||
canEdit = false,
|
||||
onBackToDocuments,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const token = useSelector(selectToken);
|
||||
const currentUserSub = token
|
||||
? ((decodeJwtPayload(token)?.sub as string | undefined) ?? null)
|
||||
: null;
|
||||
|
||||
const [pages, setPages] = useState<WikiPageNode[]>([]);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [page, setPage] = useState<WikiPageNode | null>(null);
|
||||
const [content, setContent] = useState<string>('');
|
||||
const [loadingPages, setLoadingPages] = useState(true);
|
||||
const [loadingContent, setLoadingContent] = useState(false);
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [draft, setDraft] = useState<string>('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
setLoadingPages(true);
|
||||
@@ -59,7 +106,10 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
}, [docId, token]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsEditing(false);
|
||||
setEditError(null);
|
||||
if (!selectedPath) {
|
||||
setPage(null);
|
||||
setContent('');
|
||||
return;
|
||||
}
|
||||
@@ -70,7 +120,9 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setContent(data?.page?.content ?? '');
|
||||
const node: WikiPageNode | null = data?.page ?? null;
|
||||
setPage(node);
|
||||
setContent((data?.page?.content as string) ?? '');
|
||||
})
|
||||
.catch((error) => console.error('Error loading wiki page:', error))
|
||||
.finally(() => {
|
||||
@@ -81,6 +133,76 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
};
|
||||
}, [docId, selectedPath, token]);
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(content);
|
||||
setEditError(null);
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const cancelEditing = () => {
|
||||
setIsEditing(false);
|
||||
setEditError(null);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedPath) return;
|
||||
setSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
const outcome = await saveWikiPage(
|
||||
userService,
|
||||
docId,
|
||||
selectedPath,
|
||||
draft,
|
||||
page?.version,
|
||||
token,
|
||||
);
|
||||
switch (outcome.status) {
|
||||
case 'saved':
|
||||
if (outcome.page) setPage(outcome.page);
|
||||
setContent(draft);
|
||||
setIsEditing(false);
|
||||
break;
|
||||
case 'conflict':
|
||||
if (outcome.page) {
|
||||
setPage(outcome.page);
|
||||
setContent(outcome.page.content ?? '');
|
||||
}
|
||||
setEditError(t('settings.sources.wiki.conflict'));
|
||||
break;
|
||||
case 'forbidden':
|
||||
setEditError(t('settings.sources.wiki.forbidden'));
|
||||
break;
|
||||
default:
|
||||
setEditError(t('settings.sources.wiki.saveFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving wiki page:', error);
|
||||
setEditError(t('settings.sources.wiki.saveFailed'));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const provenanceLabel = (via?: string | null, by?: string | null): string =>
|
||||
t(`settings.sources.wiki.stamp.${provenanceKey(via, by, currentUserSub)}`);
|
||||
|
||||
const renderStamp = () => {
|
||||
if (!page) return null;
|
||||
const who = provenanceLabel(page.updated_via, page.updated_by);
|
||||
const when = formatRelativeTime(page.updated_at);
|
||||
const parts = [t('settings.sources.wiki.stamp.editedBy', { who })];
|
||||
if (when) parts.push(when);
|
||||
if (page.version != null) {
|
||||
parts.push(
|
||||
t('settings.sources.wiki.stamp.version', { version: page.version }),
|
||||
);
|
||||
}
|
||||
return (
|
||||
<p className="text-muted-foreground mb-3 text-xs">{parts.join(' · ')}</p>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-4 flex items-center">
|
||||
@@ -109,19 +231,19 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{pages.map((page) => (
|
||||
<li key={page.path}>
|
||||
{pages.map((p) => (
|
||||
<li key={p.path}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedPath(page.path)}
|
||||
onClick={() => setSelectedPath(p.path)}
|
||||
className={`w-full truncate rounded-lg px-3 py-2 text-left text-sm transition-colors ${
|
||||
selectedPath === page.path
|
||||
selectedPath === p.path
|
||||
? 'bg-muted text-foreground dark:bg-accent'
|
||||
: 'text-muted-foreground hover:bg-muted dark:hover:bg-accent'
|
||||
}`}
|
||||
title={page.path}
|
||||
title={p.path}
|
||||
>
|
||||
{page.title || page.path}
|
||||
{p.title || p.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
@@ -133,56 +255,72 @@ const WikiViewer: React.FC<WikiViewerProps> = ({
|
||||
{loadingContent ? (
|
||||
<SkeletonLoader count={4} />
|
||||
) : selectedPath ? (
|
||||
<article className="text-foreground max-w-none text-sm leading-relaxed wrap-break-word">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="mt-4 mb-2 text-xl font-semibold">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="mt-4 mb-2 text-lg font-semibold">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="mt-3 mb-2 text-base font-semibold">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => <p className="mb-3">{children}</p>,
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-3 list-inside list-disc pl-4">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-3 list-inside list-decimal pl-4">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
a: ({ children, href }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-primary underline"
|
||||
<div className="flex flex-col">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
{renderStamp()}
|
||||
{canEdit && !isEditing && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={startEditing}
|
||||
>
|
||||
{t('settings.sources.wiki.edit')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editError && (
|
||||
<p
|
||||
role="alert"
|
||||
className="border-border text-muted-foreground mb-3 rounded-md border px-3 py-2 text-xs"
|
||||
>
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isEditing ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder={t('settings.sources.wiki.editPlaceholder')}
|
||||
className="border-border bg-card text-foreground min-h-[320px] w-full resize-y rounded-md border p-3 font-mono text-sm leading-relaxed"
|
||||
aria-label={selectedPath}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
code: ({ children }) => (
|
||||
<code className="dark:bg-accent rounded-md bg-gray-200 px-1.5 py-0.5 text-xs">
|
||||
{children}
|
||||
</code>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
{saving
|
||||
? t('settings.sources.wiki.saving')
|
||||
: t('settings.sources.wiki.save')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={cancelEditing}
|
||||
disabled={saving}
|
||||
>
|
||||
{t('settings.sources.wiki.cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<article className="text-foreground max-w-none text-sm leading-relaxed wrap-break-word">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={markdownComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</article>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground py-2 text-sm">
|
||||
{t('settings.sources.wiki.selectPage')}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
formatRelativeTime,
|
||||
provenanceKey,
|
||||
saveWikiPage,
|
||||
} from './wikiViewerUtils';
|
||||
|
||||
const jsonResponse = (status: number, body: unknown): Response =>
|
||||
({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: () => Promise.resolve(body),
|
||||
}) as unknown as Response;
|
||||
|
||||
describe('formatRelativeTime', () => {
|
||||
const now = Date.parse('2026-06-22T12:00:00Z');
|
||||
|
||||
it('returns null for empty or invalid values', () => {
|
||||
expect(formatRelativeTime(null, now)).toBeNull();
|
||||
expect(formatRelativeTime(undefined, now)).toBeNull();
|
||||
expect(formatRelativeTime('not-a-date', now)).toBeNull();
|
||||
});
|
||||
|
||||
it('formats a recent edit in minutes', () => {
|
||||
const fiveMinAgo = new Date(now - 5 * 60000).toISOString();
|
||||
expect(formatRelativeTime(fiveMinAgo, now)).toBe('5 minutes ago');
|
||||
});
|
||||
|
||||
it('formats an older edit in days', () => {
|
||||
const threeDaysAgo = new Date(now - 3 * 86400000).toISOString();
|
||||
expect(formatRelativeTime(threeDaysAgo, now)).toBe('3 days ago');
|
||||
});
|
||||
});
|
||||
|
||||
describe('provenanceKey', () => {
|
||||
it('maps the current user to "you"', () => {
|
||||
expect(provenanceKey('human', 'user-1', 'user-1')).toBe('you');
|
||||
});
|
||||
|
||||
it('maps agent and human writes', () => {
|
||||
expect(provenanceKey('agent', 'someone', 'user-1')).toBe('agent');
|
||||
expect(provenanceKey('human', 'someone', 'user-1')).toBe('human');
|
||||
});
|
||||
|
||||
it('falls back to "unknown" for missing provenance', () => {
|
||||
expect(provenanceKey(null, null, null)).toBe('unknown');
|
||||
expect(provenanceKey('weird', 'someone', 'user-1')).toBe('unknown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveWikiPage', () => {
|
||||
it('sends expected_version and returns the saved page', async () => {
|
||||
const updateWikiPage = vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse(200, { page: { path: '/a.md', version: 3 } }),
|
||||
);
|
||||
const getWikiPage = vi.fn();
|
||||
const service = { updateWikiPage, getWikiPage };
|
||||
|
||||
const outcome = await saveWikiPage(
|
||||
service,
|
||||
'src-1',
|
||||
'/a.md',
|
||||
'new body',
|
||||
2,
|
||||
'tok',
|
||||
);
|
||||
|
||||
expect(updateWikiPage).toHaveBeenCalledWith(
|
||||
'src-1',
|
||||
{ path: '/a.md', content: 'new body', expected_version: 2 },
|
||||
'tok',
|
||||
);
|
||||
expect(getWikiPage).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({
|
||||
status: 'saved',
|
||||
page: { path: '/a.md', version: 3 },
|
||||
});
|
||||
});
|
||||
|
||||
it('reloads the latest page on a 409 conflict', async () => {
|
||||
const updateWikiPage = vi.fn().mockResolvedValue(jsonResponse(409, {}));
|
||||
const getWikiPage = vi.fn().mockResolvedValue(
|
||||
jsonResponse(200, {
|
||||
page: { path: '/a.md', version: 5, content: 'their edit' },
|
||||
}),
|
||||
);
|
||||
const service = { updateWikiPage, getWikiPage };
|
||||
|
||||
const outcome = await saveWikiPage(
|
||||
service,
|
||||
'src-1',
|
||||
'/a.md',
|
||||
'my edit',
|
||||
2,
|
||||
'tok',
|
||||
);
|
||||
|
||||
expect(getWikiPage).toHaveBeenCalledWith('src-1', '/a.md', 'tok');
|
||||
expect(outcome).toEqual({
|
||||
status: 'conflict',
|
||||
page: { path: '/a.md', version: 5, content: 'their edit' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reports forbidden on 403 without reloading', async () => {
|
||||
const updateWikiPage = vi.fn().mockResolvedValue(jsonResponse(403, {}));
|
||||
const getWikiPage = vi.fn();
|
||||
const service = { updateWikiPage, getWikiPage };
|
||||
|
||||
const outcome = await saveWikiPage(
|
||||
service,
|
||||
'src-1',
|
||||
'/a.md',
|
||||
'x',
|
||||
1,
|
||||
'tok',
|
||||
);
|
||||
|
||||
expect(getWikiPage).not.toHaveBeenCalled();
|
||||
expect(outcome).toEqual({ status: 'forbidden' });
|
||||
});
|
||||
|
||||
it('reports a generic error on other failures', async () => {
|
||||
const updateWikiPage = vi.fn().mockResolvedValue(jsonResponse(400, {}));
|
||||
const getWikiPage = vi.fn();
|
||||
const service = { updateWikiPage, getWikiPage };
|
||||
|
||||
const outcome = await saveWikiPage(
|
||||
service,
|
||||
'src-1',
|
||||
'/a.md',
|
||||
'x',
|
||||
1,
|
||||
'tok',
|
||||
);
|
||||
|
||||
expect(outcome).toEqual({ status: 'error' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
export interface WikiPageNode {
|
||||
path: string;
|
||||
title?: string | null;
|
||||
token_count?: number;
|
||||
embed_status?: string;
|
||||
version?: number;
|
||||
updated_by?: string | null;
|
||||
updated_via?: string | null;
|
||||
updated_at?: string | null;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
const RELATIVE_UNITS: [Intl.RelativeTimeFormatUnit, number][] = [
|
||||
['year', 31536000000],
|
||||
['month', 2592000000],
|
||||
['day', 86400000],
|
||||
['hour', 3600000],
|
||||
['minute', 60000],
|
||||
];
|
||||
|
||||
export function formatRelativeTime(
|
||||
value: string | null | undefined,
|
||||
now: number = Date.now(),
|
||||
): string | null {
|
||||
if (!value) return null;
|
||||
const then = new Date(value).getTime();
|
||||
if (Number.isNaN(then)) return null;
|
||||
const diffMs = now - then;
|
||||
const formatter = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
|
||||
for (const [unit, ms] of RELATIVE_UNITS) {
|
||||
if (Math.abs(diffMs) >= ms) {
|
||||
return formatter.format(-Math.round(diffMs / ms), unit);
|
||||
}
|
||||
}
|
||||
return formatter.format(0, 'second');
|
||||
}
|
||||
|
||||
export function provenanceKey(
|
||||
via?: string | null,
|
||||
by?: string | null,
|
||||
currentUserSub?: string | null,
|
||||
): 'you' | 'agent' | 'human' | 'unknown' {
|
||||
if (by && currentUserSub && by === currentUserSub) return 'you';
|
||||
if (via === 'agent') return 'agent';
|
||||
if (via === 'human') return 'human';
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
export type SaveOutcome =
|
||||
| { status: 'saved'; page: WikiPageNode | null }
|
||||
| { status: 'conflict'; page: WikiPageNode | null }
|
||||
| { status: 'forbidden' }
|
||||
| { status: 'error' };
|
||||
|
||||
interface WikiSaveService {
|
||||
updateWikiPage: (
|
||||
sourceId: string,
|
||||
data: { path: string; content: string; expected_version?: number },
|
||||
token: string | null,
|
||||
) => Promise<Response>;
|
||||
getWikiPage: (
|
||||
sourceId: string,
|
||||
path: string,
|
||||
token: string | null,
|
||||
) => Promise<Response>;
|
||||
}
|
||||
|
||||
export async function saveWikiPage(
|
||||
service: WikiSaveService,
|
||||
sourceId: string,
|
||||
path: string,
|
||||
draft: string,
|
||||
expectedVersion: number | undefined,
|
||||
token: string | null,
|
||||
): Promise<SaveOutcome> {
|
||||
const response = await service.updateWikiPage(
|
||||
sourceId,
|
||||
{ path, content: draft, expected_version: expectedVersion },
|
||||
token,
|
||||
);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return { status: 'saved', page: data?.page ?? null };
|
||||
}
|
||||
if (response.status === 409) {
|
||||
const refreshed = await service.getWikiPage(sourceId, path, token);
|
||||
const data = await refreshed.json();
|
||||
return { status: 'conflict', page: data?.page ?? null };
|
||||
}
|
||||
if (response.status === 403) return { status: 'forbidden' };
|
||||
return { status: 'error' };
|
||||
}
|
||||
@@ -106,7 +106,23 @@
|
||||
"wiki": {
|
||||
"view": "View wiki",
|
||||
"empty": "This wiki has no pages yet.",
|
||||
"selectPage": "Select a page to view its contents."
|
||||
"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…",
|
||||
"stamp": {
|
||||
"editedBy": "Edited by {{who}}",
|
||||
"agent": "agent",
|
||||
"human": "human",
|
||||
"you": "you",
|
||||
"unknown": "unknown",
|
||||
"version": "v{{version}}"
|
||||
}
|
||||
},
|
||||
"editConfig": "Source settings",
|
||||
"shareWithTeam": "Share with team",
|
||||
|
||||
@@ -436,6 +436,10 @@ export default function Sources({
|
||||
<WikiViewer
|
||||
docId={documentToView.id || ''}
|
||||
sourceName={documentToView.name}
|
||||
canEdit={
|
||||
documentToView.ownership !== 'team' ||
|
||||
documentToView.team_access === 'editor'
|
||||
}
|
||||
onBackToDocuments={() => setDocumentToView(undefined)}
|
||||
/>
|
||||
) : documentToView.isNested ? (
|
||||
|
||||
@@ -39,6 +39,7 @@ class _FakeWikiRepo:
|
||||
content,
|
||||
title=None,
|
||||
updated_by=None,
|
||||
updated_via=None,
|
||||
expected_version=None,
|
||||
):
|
||||
key = (source_id, path)
|
||||
@@ -55,6 +56,7 @@ class _FakeWikiRepo:
|
||||
"title": title,
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
"version": existing["version"] + 1,
|
||||
}
|
||||
)
|
||||
@@ -66,6 +68,7 @@ class _FakeWikiRepo:
|
||||
"title": title,
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
"version": existing["version"] + 1,
|
||||
}
|
||||
)
|
||||
@@ -78,6 +81,7 @@ class _FakeWikiRepo:
|
||||
"title": title,
|
||||
"content_hash": content_hash,
|
||||
"updated_by": updated_by,
|
||||
"updated_via": updated_via,
|
||||
"version": 1,
|
||||
"token_count": len(content),
|
||||
}
|
||||
@@ -210,6 +214,20 @@ class TestCreateView:
|
||||
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"
|
||||
|
||||
@@ -162,7 +162,9 @@ class TestWikiPages:
|
||||
"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)
|
||||
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(
|
||||
@@ -174,6 +176,8 @@ class TestWikiPages:
|
||||
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
|
||||
@@ -270,6 +274,33 @@ class TestWikiPage:
|
||||
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
|
||||
@@ -455,8 +486,10 @@ class TestWikiPageEdit:
|
||||
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
|
||||
|
||||
@@ -53,6 +53,26 @@ class TestUpsert:
|
||||
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):
|
||||
|
||||
@@ -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")
|
||||
Reference in new issue
Block a user