eigent/backend/app/workspace_git/observer.py

174 lines
6.1 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Detect non-Eigent changes in long-lived private Git worktrees."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from app.run_journal import SQLiteRunJournal
from app.workspace_git.backend import GitBackend
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ExternalGitChange:
repository_id: str
owner_role: str
owner_id: str
worktree_path: str
expected_head: str | None
observed_head: str | None
operation_state: str
@dataclass(frozen=True)
class ExternalGitObservation:
changes: tuple[ExternalGitChange, ...]
failed_repository_ids: tuple[str, ...]
class WorkspaceGitObserver:
"""Classify private-worktree divergence without touching User files."""
def __init__(
self,
journal: SQLiteRunJournal,
*,
git_backend: GitBackend | None = None,
) -> None:
self.journal = journal
self.git = git_backend or GitBackend()
def inspect_all(self) -> ExternalGitObservation:
changes: list[ExternalGitChange] = []
failed: list[str] = []
projects = self.journal.list_project_git_states()
runs = self.journal.list_run_git_materializations()
open_change_sets = {
value.change_set_id: value
for value in self.journal.list_git_change_sets(states=("open",))
}
runs_with_pending_intents = {
open_change_sets[intent.change_set_id].run_id
for intent in self.journal.list_git_mutation_intents(
statuses=("prepared",)
)
if intent.change_set_id in open_change_sets
}
repositories_with_dispatched_operations = {
operation.repository_id
for operation in self.journal.list_git_operations(
statuses=("dispatched",)
)
}
for repository in self.journal.list_git_repositories():
if repository.repository_role != "content":
continue
try:
if repository.repository_id in (
repositories_with_dispatched_operations
):
continue
for project in projects:
if project.repository_id != repository.repository_id:
continue
change = self._inspect_project(project)
if change is not None:
changes.append(change)
for run in runs:
if (
run.repository_id != repository.repository_id
or run.run_id in runs_with_pending_intents
):
continue
change = self._inspect_run(run)
if change is not None:
changes.append(change)
except Exception:
logger.exception(
"Git external-mutation observation failed for one repository",
extra={"repository_id": repository.repository_id},
)
failed.append(repository.repository_id)
return ExternalGitObservation(
changes=tuple(changes),
failed_repository_ids=tuple(failed),
)
def _inspect_project(self, project) -> ExternalGitChange | None:
if (
project.state != "ready"
or project.worktree_path is None
or project.projected_head is None
):
return None
root = Path(project.worktree_path)
diagnostics = self.git.diagnostics(root)
if self.git.worktree_matches_commit(root, project.projected_head):
return None
self.journal.mark_project_git_attention(
project_id=project.project_id,
expected_version=project.version,
)
return ExternalGitChange(
repository_id=project.repository_id,
owner_role="project",
owner_id=project.project_id,
worktree_path=str(root),
expected_head=project.projected_head,
observed_head=diagnostics.state_token.head_oid,
operation_state=diagnostics.state_token.operation_state,
)
def _inspect_run(self, run) -> ExternalGitChange | None:
if (
run.materialization_state not in {"materialized", "promoted"}
or run.worktree_path is None
):
return None
root = Path(run.worktree_path)
checkpoint = self.journal.get_latest_git_checkpoint_for_target(
repository_id=run.repository_id,
target_role="run",
target_id=run.run_id,
)
expected_head = (
checkpoint.commit_oid
if checkpoint is not None
else run.workspace_base_commit
)
if expected_head is None:
return None
diagnostics = self.git.diagnostics(root)
if self.git.worktree_matches_commit(root, expected_head):
return None
self.journal.mark_run_git_attention(
run_id=run.run_id,
expected_version=run.version,
)
return ExternalGitChange(
repository_id=run.repository_id,
owner_role="run",
owner_id=run.run_id,
worktree_path=str(root),
expected_head=expected_head,
observed_head=diagnostics.state_token.head_oid,
operation_state=diagnostics.state_token.operation_state,
)