diff --git a/surfsense_backend/app/knowledge_store/engines/base.py b/surfsense_backend/app/knowledge_store/engines/base.py index a2dd8c41b..4abed36e9 100644 --- a/surfsense_backend/app/knowledge_store/engines/base.py +++ b/surfsense_backend/app/knowledge_store/engines/base.py @@ -17,7 +17,10 @@ class Revision: """One recorded point in a workspace's history (a whole-tree snapshot).""" id: str + #: Whose content change this is (the acting user). author: str + #: Who recorded it (the agent for agent turns; equals author otherwise). + committer: str message: str created_at: datetime @@ -64,10 +67,12 @@ class VersionedContentEngine(ABC): removes: Iterable[str], message: str, author: str, + committer: str | None = None, ) -> str | None: """Append ``writes`` and ``removes`` to history as one revision. - Returns the revision id, or ``None`` when nothing changed. + ``committer`` defaults to ``author``. Returns the revision id, or + ``None`` when nothing changed. """ @abstractmethod diff --git a/surfsense_backend/app/knowledge_store/engines/git.py b/surfsense_backend/app/knowledge_store/engines/git.py index 10165da08..d79da9080 100644 --- a/surfsense_backend/app/knowledge_store/engines/git.py +++ b/surfsense_backend/app/knowledge_store/engines/git.py @@ -62,6 +62,7 @@ class GitContentEngine(VersionedContentEngine): removes: Iterable[str], message: str, author: str, + committer: str | None = None, ) -> str | None: self._ensure_exists() repo = Repo(str(self._path)) @@ -78,12 +79,11 @@ class GitContentEngine(VersionedContentEngine): if not self._has_pending_changes(repo): return None - author_bytes = author.encode() revision = porcelain.commit( repo, message=message.encode(), - author=author_bytes, - committer=author_bytes, + author=author.encode(), + committer=(committer or author).encode(), ) return revision.decode() finally: @@ -289,6 +289,7 @@ class GitContentEngine(VersionedContentEngine): return Revision( id=commit.id.decode(), author=commit.author.decode(), + committer=commit.committer.decode(), message=commit.message.decode().strip(), created_at=datetime.fromtimestamp(commit.commit_time, tz=UTC), ) diff --git a/surfsense_backend/app/knowledge_store/store.py b/surfsense_backend/app/knowledge_store/store.py index bdc6903eb..7d03b3a00 100644 --- a/surfsense_backend/app/knowledge_store/store.py +++ b/surfsense_backend/app/knowledge_store/store.py @@ -38,14 +38,19 @@ class KnowledgeStore: return cls(workspace_id, engine) @asynccontextmanager - async def transaction(self, *, message: str, author: str): + async def transaction( + self, *, message: str, author: str, committer: str | None = None + ): """Atomic unit of work: verbs staged in the scope become one revision - on clean exit; an exception records nothing.""" + on clean exit; an exception records nothing. + + ``author`` is whose content change this is; ``committer`` (default + ``author``) is who recorded it — the agent identity for agent turns.""" tx = Transaction() yield tx async with workspace_write_lock(self._workspace_id): tx.revision = await asyncio.to_thread( - self._record_revision, tx, message, author + self._record_revision, tx, message, author, committer ) async def read_as_of(self, revision: str, path: str) -> bytes: @@ -100,10 +105,14 @@ class KnowledgeStore: return self._engine.compute_content_id(data) def _record_revision( - self, tx: Transaction, message: str, author: str + self, tx: Transaction, message: str, author: str, committer: str | None ) -> str | None: """Resolve the transaction into one change set and record it.""" writes, removes = tx.resolve(self._engine.read) return self._engine.record( - writes=writes, removes=removes, message=message, author=author + writes=writes, + removes=removes, + message=message, + author=author, + committer=committer, ) diff --git a/surfsense_backend/tests/unit/knowledge_store/engines/test_git.py b/surfsense_backend/tests/unit/knowledge_store/engines/test_git.py index d911e059e..fcaea3806 100644 --- a/surfsense_backend/tests/unit/knowledge_store/engines/test_git.py +++ b/surfsense_backend/tests/unit/knowledge_store/engines/test_git.py @@ -89,6 +89,24 @@ class TestHistoryQueries: assert rev.author == AUTHOR assert rev.created_at.tzinfo is not None + def test_revision_distinguishes_author_from_committer(self, engine): + agent = "SurfSense Agent " + engine.record( + writes={"a.xml": b"1"}, + removes=[], + message="a1", + author=AUTHOR, + committer=agent, + ) + rev = engine.list_revisions()[0] + assert rev.author == AUTHOR + assert rev.committer == agent + + def test_committer_defaults_to_author(self, engine): + engine.record(writes={"a.xml": b"1"}, removes=[], message="a1", author=AUTHOR) + rev = engine.list_revisions()[0] + assert rev.committer == AUTHOR + def test_list_changes_reports_kinds_and_content_ids(self, engine): first = engine.record( writes={"a.xml": b"a1", "b.xml": b"b1"},