Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions GRAPHLINK_REPO_NAVIGATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ Separately, and still accurate: `backend/domain/` has a full document-NODE model

## Concrete Node and Connection Taxonomy

### Real node kinds today (verified directly against the `kind=` literals now in `backend/domain/graph.py`, 16 total)
### Real node kinds today (verified directly against the `kind=` literals now in `backend/domain/graph.py`, 17 total)

| `kind` string | User-facing name (plugin picker, where applicable) | React component |
|---|---|---|
Expand All @@ -172,13 +172,14 @@ Separately, and still accurate: `backend/domain/` has a full document-NODE model
| `web_research` | Web Research | `WebResearchNodeView.tsx` |
| `artifact` | Artifact / Drafter | `ArtifactNodeView.tsx` |
| `gitlink` | Gitlink | `GitlinkNodeView.tsx` |
| `code_review` | Review Lens | `CodeReviewNodeView.tsx` |
| `code_sandbox` | Virtual Environment Runner | `CodeSandboxNodeView.tsx` |
| `note` | (System Prompt picker entry creates one) | `NoteNodeView.tsx` |
| `frame` | (Create Frame command) | `GroupNodeView.tsx` (shared with `container`, distinguished by `data.groupKind`) |
| `container` | (Create Container command) | `GroupNodeView.tsx` |
| `chart` | Chart | `ChartNodeView.tsx` |

"System Prompt" is a plugin-picker entry, not a distinct node kind - it creates a `note` node with `is_system_prompt=True`. There is no separate `reasoning`/`workflow`/`graph_diff`/`quality_gate`/`code_review` node kind - those plugin categories were removed before the Qt-removal effort even began and were never ported.
"System Prompt" is a plugin-picker entry, not a distinct node kind - it creates a `note` node with `is_system_prompt=True`. There is no separate `reasoning`/`workflow`/`graph_diff`/`quality_gate` node kind - those plugin categories were removed before the Qt-removal effort even began and were never ported. (`code_review` is the one exception: a NEW first-party kind added post-migration for Review Lens, not a port of the removed advisor plugin.)

### Connections: one unified model, not 13 parallel lists

Expand Down Expand Up @@ -257,7 +258,11 @@ This is the live registration order in `backend/plugins.py::_PLUGINS` / `_CATEGO

- `Artifact / Drafter` - creates an `artifact` node.

`Validation & Delivery` is defined in `_CATEGORY_META` but has zero plugins mapped to it today, so `get_plugin_categories()` filters it out of the returned listing (same "skip empty categories" algorithm the deleted `PluginPortal` used). There is no `Reasoning`/`Workflow Architect`/`Quality Gate`/`Code Review Agent`/`Branch Lens (GraphDiff)` plugin - those were removed well before the Qt-removal effort began and were never carried into `backend/plugins.py`.
### Validation & Delivery

- `Review Lens` - creates a `code_review` node (guided PR review: fetch diff, guided walkthrough, severity-tiered findings, scorecard).

`Validation & Delivery` holds Review Lens (the first post-migration first-party addition). There is no `Reasoning`/`Workflow Architect`/`Quality Gate`/`Branch Lens (GraphDiff)` plugin - those were removed well before the Qt-removal effort began and were never carried into `backend/plugins.py`.

## Concrete File Index

Expand Down
220 changes: 220 additions & 0 deletions backend/agent_dispatch/code_review.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""CodeReviewDispatchOps - Review Lens dispatch: PR-diff fetch plus the
Review and Ask surfaces.

A MIXIN, not a standalone class: every method operates on the composing
class's shared state established by DispatcherCoreOps.__init__ - it is
composed exactly once, by backend/agents.py's
`class AgentDispatcher(DispatcherCoreOps, ...)`.

Method bodies follow backend/agent_dispatch/gitlink.py's own shapes
verbatim in structure (the plain-blocking-action skeleton with inline
pending_request_id claim for fetch/ask; the fire-and-forget RunRegistry-
claimed background task with cooperative cancel_event for the review run
itself); only the Review Lens payloads differ. Any name that lives in
backend/agents.py's module namespace (module helpers, constants, names
imported into it) is accessed late-bound as `agents_module.<name>`
through an in-body deferred import, NEVER via a module-top import here: a
top-level `from backend.agents import X` would be a circular import
(agents.py imports this module) AND would freeze the name at import time,
making the test suite's `monkeypatch.setattr(backend.agents, "X", ...)`
patches invisible to these methods. The deferred-import-then-attribute
pattern resolves the name on backend.agents at call time, so those patch
seams keep working with zero test changes.
"""

from __future__ import annotations

import asyncio
import threading
import uuid
from typing import TYPE_CHECKING

if TYPE_CHECKING:
from backend.events import SessionBus


class CodeReviewDispatchOps:
"""Review Lens dispatch: PR-diff fetch plus the Review and Ask surfaces (mixin - see module docstring)."""

async def _run_code_review_blocking_action(
self,
*,
bus: SessionBus,
notifications_state,
node,
action,
timeout: float,
timeout_message: str,
error_log_message: str,
error_notify_prefix: str,
default=None,
):
"""Shared skeleton behind the two PLAIN code-review async methods
below (fetch_code_review_diff/ask_code_review_question) - the same
shape as GitlinkDispatchOps._run_gitlink_blocking_action: these two
(unlike start_code_review_run) claim node.pending_request_id inline
and are awaited directly by the caller, with no RunRegistry/
cancel_event involvement. `action` is a zero-arg async callable
doing the actual blocking work (already wrapped in asyncio.to_thread
by the caller); everything around it - the busy marker
claim/release, the "scene" publishes bracketing it, and the
timeout/exception -> notification handling - is shared."""
from backend import agents as agents_module # deferred: patch-seam + circular-import safety
request_id = uuid.uuid4().hex
node.pending_request_id = request_id
await bus.publish("scene")
try:
return await asyncio.wait_for(action(), timeout=timeout)
except asyncio.TimeoutError:
notifications_state.show(timeout_message, "error")
await bus.publish("notification")
return default
except Exception as exc:
agents_module.logger.exception(error_log_message)
notifications_state.show(f"{error_notify_prefix}: {exc}", "error")
await bus.publish("notification")
return default
finally:
node.pending_request_id = None
await bus.publish("scene")

async def fetch_code_review_diff(self, *, bus: SessionBus, notifications_state, node, pr_url: str):
from backend import agents as agents_module # deferred: patch-seam + circular-import safety
async def _action():
return await asyncio.to_thread(agents_module._fetch_code_review_bundle, self._settings_manager, pr_url)

return await self._run_code_review_blocking_action(
bus=bus,
notifications_state=notifications_state,
node=node,
action=_action,
timeout=agents_module.CODE_REVIEW_DIFF_TIMEOUT_SECONDS,
timeout_message=(
"Fetching the pull-request diff stopped responding before the request "
"completed. Please try again."
),
error_log_message="code review diff fetch failed",
error_notify_prefix="Failed to fetch the pull-request diff",
default=None,
)

async def start_code_review_run(
self,
*,
bus: SessionBus,
notifications_state,
node,
node_id: str,
bundle: dict,
on_success,
on_failure,
) -> None:
"""Review Lens's Run Review action - the same fire-and-forget shape
as GitlinkDispatchOps.start_gitlink_run: the caller returns
immediately after this schedules its background task; the eventual
result lands via on_success/on_failure plus a "scene" republish.

Cooperative cancellation only, via a threading.Event (the review
engine has no cancellation primitive of its own) - the checkpoint
is placed AFTER the blocking call returns, so a cancel requested
while the model call is already in flight discards the result
rather than truly interrupting the underlying network call.

Busy guard: node.pending_request_id is the shared busy marker for
EVERY code-review action on this node (fetch included) - a Run
cannot start while a fetch or an Ask is in flight on the SAME
node, and vice versa. The ONE exception is the caller's own
synchronous placeholder claim (backend/api/intents_code_review.py
claims _NODE_RUN_CLAIM_PLACEHOLDER before calling here) - this
method recognizes ONLY that exact value as "already claimed by my
own caller" and overwrites it, rather than rejecting a request
its own caller just admitted.

self._runs.claim() happens in that SAME synchronous stretch,
alongside node.pending_request_id's own claim - never consulted
via is_busy (node.pending_request_id remains the sole real guard;
the registry is pure task/cancel_event bookkeeping into the
shared cancel()/cancel_all() sweep)."""
from backend import agents as agents_module # deferred: patch-seam + circular-import safety
if node.pending_request_id and node.pending_request_id != agents_module._NODE_RUN_CLAIM_PLACEHOLDER:
notifications_state.show("Review Lens is already busy for this node.", "info")
await bus.publish("notification")
return

cancel_event = threading.Event()
handle = self._runs.claim("code_review_run", node_id=node_id, cancel_event=cancel_event)
request_id = handle.request_id
node.pending_request_id = request_id
await bus.publish("scene")

async def _run():
try:
result = await asyncio.wait_for(
asyncio.to_thread(agents_module._call_review_lens_agent, bundle),
timeout=agents_module.CODE_REVIEW_RUN_TIMEOUT_SECONDS,
)
if cancel_event.is_set():
notifications_state.show("Review Lens run cancelled.", "info")
await bus.publish("notification")
else:
on_success(result)
await bus.publish("scene")
except asyncio.TimeoutError:
cancel_event.set()
notifications_state.show(
"Review Lens stopped responding before the request completed. "
"Please try again.",
"error",
)
await bus.publish("notification")
except Exception as exc:
agents_module.logger.exception("code review dispatch failed")
on_failure(f"Review Lens run failed: {exc}")
notifications_state.show(f"Review Lens run failed: {exc}", "error")
await bus.publish("notification")
finally:
self._runs.release(request_id)
# Only clear if this task's OWN request_id is still the one
# recorded - a stale, already-superseded task finishing
# late must never clobber a newer legitimate busy marker.
if node.pending_request_id == request_id:
node.pending_request_id = None
await bus.publish("scene")

self._runs.attach_task(handle, asyncio.create_task(_run()))

async def ask_code_review_question(
self, *, bus: SessionBus, notifications_state, node, question: str,
review_summary: str,
):
"""One follow-up Q&A over the node's already-fetched diff - the
"chat about the changes" surface. A plain blocking action (same
skeleton as the fetch above): the answer lands via
append_code_review_qa in the caller, not here."""
from backend import agents as agents_module # deferred: patch-seam + circular-import safety
diff_text = node.state.code_review_diff_text
async def _action():
return await asyncio.to_thread(
agents_module._ask_review_lens_agent, diff_text, question, review_summary,
)

return await self._run_code_review_blocking_action(
bus=bus,
notifications_state=notifications_state,
node=node,
action=_action,
timeout=agents_module.CODE_REVIEW_ASK_TIMEOUT_SECONDS,
timeout_message=(
"Answering that question stopped responding before the request "
"completed. Please try again."
),
error_log_message="code review ask failed",
error_notify_prefix="Failed to answer that question",
default=None,
)

def cancel_code_review(self, request_id: str) -> bool:
"""kind="code_review_run": see RunRegistry.cancel's own docstring
for why kind= is passed now that code_review_run shares self._runs
with other cancel_event-bearing kinds."""
return self._runs.cancel(request_id, kind="code_review_run")
42 changes: 42 additions & 0 deletions backend/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@
from graphlink_settings_store import SettingsManager # type hint only
from graphlink_plugins.common.github_client import GitHubRestClient
from graphlink_plugins.gitlink.agent import GitlinkAgent, _fingerprint_changes, _is_repo_text_path # noqa: F401
from graphlink_plugins.review_lens.diff_fetch import fetch_pr_review_bundle
from graphlink_plugins.review_lens.pr_url import parse_pr_url
from graphlink_plugins.review_lens.review_engine import ReviewLensAgent
from graphlink_plugins.gitlink.repository import (
GitlinkRepository,
apply_change_set,
Expand Down Expand Up @@ -122,6 +125,7 @@
from backend.structured_output import StructuredOutputError, respond_json
from backend.agent_dispatch.builder import BuilderDispatchOps
from backend.agent_dispatch.chat import ChatDispatchOps
from backend.agent_dispatch.code_review import CodeReviewDispatchOps
from backend.agent_dispatch.code_sandbox import CodeSandboxDispatchOps
from backend.agent_dispatch.content import ContentDispatchOps
from backend.agent_dispatch.core import DispatcherCoreOps
Expand Down Expand Up @@ -171,6 +175,15 @@
# fetch per selected path); local-root-backed builds are pure disk I/O and
# finish well under this.
GITLINK_CONTEXT_TIMEOUT_SECONDS = 300
# Review Lens: one PR-metadata GET + up to two pages of file-listing GETs +
# one diff download (network-timeout-capped at 60s by diff_fetch itself).
CODE_REVIEW_DIFF_TIMEOUT_SECONDS = 120
# Review Lens: one LLM completion over up to 45,000 chars of diff
# (review_engine's MAX_DIFF_MODEL_CHARS) - same call-count shape as a
# Gitlink run, with a comparable input size, hence the same watchdog.
CODE_REVIEW_RUN_TIMEOUT_SECONDS = 600
# Review Lens: one follow-up Q&A completion over the same capped diff.
CODE_REVIEW_ASK_TIMEOUT_SECONDS = 300

# R5.3 post-review FIX 4(b): the sentinel value backend/canvas.py's
# run_gitlink_change_set stores into node.pending_request_id SYNCHRONOUSLY,
Expand Down Expand Up @@ -292,6 +305,7 @@ class AgentDispatcher(
ChatDispatchOps,
ResearchDispatchOps,
GitlinkDispatchOps,
CodeReviewDispatchOps,
CodeSandboxDispatchOps,
ContentDispatchOps,
):
Expand Down Expand Up @@ -719,6 +733,34 @@ def _call_gitlink_agent(payload):
return GitlinkAgent().get_response(payload)


def _fetch_code_review_bundle(settings_manager, pr_url):
"""Runs inside asyncio.to_thread. Parses the pasted PR URL, then fetches
the PR metadata + file list + unified diff via the shared GitHub REST
client (token from the session's settings, public PRs working
token-less). Returns diff_fetch.fetch_pr_review_bundle's own dict."""
owner, repo, number = parse_pr_url(pr_url)
client = GitHubRestClient(settings_manager)
return fetch_pr_review_bundle(client, owner, repo, number)


def _call_review_lens_agent(bundle):
"""Runs inside asyncio.to_thread. Reuses ReviewLensAgent.get_response
verbatim - same defensive-by-construction dict-in/dict-out contract as
_call_gitlink_agent above (a model failure degrades to the deterministic
fallback review inside the engine, never an exception)."""
return ReviewLensAgent().get_response(bundle)


def _ask_review_lens_agent(diff_text, question, review_summary):
"""Runs inside asyncio.to_thread. One follow-up Q&A over an already-
fetched diff - raises RuntimeError with a display-safe message on
empty input or model failure (the dispatcher's own _run maps it to
the node's error banner, matching every other run surface)."""
return ReviewLensAgent().answer_question(
diff_text=diff_text, question=question, review_summary=review_summary,
)


def _build_gitlink_proposal_markdown(repo, branch, result):
"""Replicates _build_proposal_markdown exactly, as a plain function
operating on GitlinkAgent.get_response's own result dict instead of a
Expand Down
Loading
Loading