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:
Alex committed 2026-06-22 20:42:37 +01:00
1 parent 0c9e0313bd
commit 919fe10ab9
16 files changed
+661 -68

No files matched your search

+5
View File
@@ -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;")
+3
View File
@@ -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:
+1
View File
@@ -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())
+6 -1
View File
@@ -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)))
+6
View File
@@ -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> =>
+201 -63
View File
@@ -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' };
}
+17 -1
View File
@@ -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",
+4
View File
@@ -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 ? (
+18
View File
@@ -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"
+34 -1
View File
@@ -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):
+77
View File
@@ -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")