feat(server): integrate MCP queries and investigation messaging
Add project-bound OAuth MCP queries and controlled analysis with scoped evidence, limits, timeouts and a nearby-model spatial query using EPSG:4326 inputs. Default nearby searches to 200 meters and five results. Integrate investigation messaging/presentation, health/readiness and typed history; remove duplicate web search and fix online simulation and compressed baseline writes. Include project migrations 023-025; keep local credentials and demo seeding excluded. Validation: 319 focused pytest checks; real PostGIS temporary-table regression; read-only Jinqian bridge radius comparison; cross-language MCP discovery/calls.
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
@@ -11,6 +12,7 @@ from uuid import uuid4
|
||||
|
||||
from app.domain.domain_errors import DomainError
|
||||
from app.infra.db.postgresql.workbench_events import lock_event_transaction
|
||||
from app.infra.db.postgresql.workbench_events import WorkbenchEventRepository
|
||||
|
||||
|
||||
class OperationalTaskRepository:
|
||||
@@ -37,6 +39,13 @@ class OperationalTaskRepository:
|
||||
(f"tjwater-operational:{self.project_id}:{key}",),
|
||||
)
|
||||
|
||||
async def try_lock(self, key: str) -> bool:
|
||||
cursor = await self.connection.execute(
|
||||
"SELECT pg_try_advisory_xact_lock(hashtextextended(%s, 0)) AS acquired",
|
||||
(f"tjwater-operational:{self.project_id}:{key}",),
|
||||
)
|
||||
return bool((await cursor.fetchone())["acquired"])
|
||||
|
||||
async def get_request(self, scope: str, request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT request_digest,response
|
||||
@@ -103,6 +112,50 @@ class OperationalTaskRepository:
|
||||
row = await cursor.fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
async def list_task_runs(self, task_id: str, limit: int, offset: int) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT * FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s AND task_id=%s
|
||||
ORDER BY generation DESC LIMIT %s OFFSET %s""",
|
||||
(self.project_id, task_id, limit, offset),
|
||||
)
|
||||
return [{key: value for key, value in row.items() if key != "project_id"}
|
||||
for row in await cursor.fetchall()]
|
||||
|
||||
async def get_latest_run(self, task_id: str) -> dict | None:
|
||||
rows = await self.list_task_runs(task_id, 1, 0)
|
||||
return rows[0] if rows else None
|
||||
|
||||
async def require_execution_storage(self) -> None:
|
||||
"""Reject claims before changing state if the inbox cannot be used."""
|
||||
cursor = await self.connection.execute(
|
||||
"SELECT to_regclass('workbench.agent_messages') IS NOT NULL AS available"
|
||||
)
|
||||
if not (await cursor.fetchone())["available"]:
|
||||
raise DomainError(
|
||||
"DEPENDENCY_UNAVAILABLE",
|
||||
"Agent message storage is not provisioned; apply "
|
||||
"resources/sql/024_agent_messages.sql to this project database before starting runs",
|
||||
status=503,
|
||||
)
|
||||
|
||||
async def pending_run_messages(self, run_id: str) -> list[str]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT message_id FROM workbench.agent_messages
|
||||
WHERE project_id=%s AND run_id=%s AND direction='to_task'
|
||||
AND status IN ('pending','enqueued') ORDER BY created_at LIMIT 100""",
|
||||
(self.project_id, run_id),
|
||||
)
|
||||
return [str(row["message_id"]) for row in await cursor.fetchall()]
|
||||
|
||||
async def fail_run_messages(self, run_id: str, failure_detail: str, now: datetime) -> None:
|
||||
await self.connection.execute(
|
||||
"""UPDATE workbench.agent_messages SET status='failed',failure_detail=%s,updated_at=%s
|
||||
WHERE project_id=%s AND run_id=%s AND direction='to_task'
|
||||
AND status IN ('pending','enqueued')""",
|
||||
(failure_detail, now, self.project_id, run_id),
|
||||
)
|
||||
|
||||
async def insert_run(self, run: dict) -> None:
|
||||
await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_runs
|
||||
@@ -142,7 +195,8 @@ class OperationalTaskRepository:
|
||||
raise DomainError("VERSION_CONFLICT", "Run revision changed", status=409)
|
||||
|
||||
async def list_runs(
|
||||
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None
|
||||
self, *, status: str | None, limit: int, cursor_created_at=None, cursor_run_id=None,
|
||||
task_type: str | None = None, task_created_from: datetime | None = None,
|
||||
) -> list[dict]:
|
||||
result = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
@@ -154,15 +208,25 @@ class OperationalTaskRepository:
|
||||
AND (%s::text IS NULL OR status=%s::text)
|
||||
AND (%s::timestamptz IS NULL OR (created_at,run_id)>(%s::timestamptz,%s::uuid))
|
||||
AND (status<>'queued' OR next_dispatch_at<=now())
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM workbench.operational_tasks t
|
||||
WHERE t.project_id=operational_task_runs.project_id
|
||||
AND t.task_id=operational_task_runs.task_id
|
||||
AND (%s::text IS NULL OR t.task_type=%s::text)
|
||||
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
||||
)
|
||||
ORDER BY created_at,run_id LIMIT %s""",
|
||||
(
|
||||
self.project_id, status, status, cursor_created_at,
|
||||
cursor_created_at, cursor_run_id, limit,
|
||||
cursor_created_at, cursor_run_id,
|
||||
task_type, task_type, task_created_from, task_created_from, limit,
|
||||
),
|
||||
)
|
||||
return [dict(row) for row in await result.fetchall()]
|
||||
|
||||
async def claim_expired_runs(self, limit: int) -> list[dict]:
|
||||
async def claim_expired_runs(
|
||||
self, limit: int, *, task_type: str | None = None, task_created_from: datetime | None = None,
|
||||
) -> list[dict]:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT run_id,task_id,generation,execution_request_id,status,revision,
|
||||
agent_session_id,lease_expires_at,heartbeat_at,started_at,finished_at,
|
||||
@@ -171,8 +235,15 @@ class OperationalTaskRepository:
|
||||
FROM workbench.operational_task_runs
|
||||
WHERE project_id=%s AND status IN ('claimed','running')
|
||||
AND lease_expires_at IS NOT NULL AND lease_expires_at<=now()
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM workbench.operational_tasks t
|
||||
WHERE t.project_id=operational_task_runs.project_id
|
||||
AND t.task_id=operational_task_runs.task_id
|
||||
AND (%s::text IS NULL OR t.task_type=%s::text)
|
||||
AND (%s::timestamptz IS NULL OR t.created_at>=%s::timestamptz)
|
||||
)
|
||||
ORDER BY lease_expires_at,run_id FOR UPDATE SKIP LOCKED LIMIT %s""",
|
||||
(self.project_id, limit),
|
||||
(self.project_id, task_type, task_type, task_created_from, task_created_from, limit),
|
||||
)
|
||||
return [dict(row) for row in await cursor.fetchall()]
|
||||
|
||||
@@ -212,6 +283,9 @@ class OperationalTaskRepository:
|
||||
)
|
||||
return event_seq
|
||||
|
||||
async def current_event_cursor(self) -> int:
|
||||
return await WorkbenchEventRepository(self.connection, self.project_id).current_cursor()
|
||||
|
||||
async def get_task_by_create_request(self, request_id: str) -> dict | None:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT document FROM workbench.operational_tasks
|
||||
@@ -246,7 +320,9 @@ class OperationalTaskRepository:
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT task.document,
|
||||
CASE WHEN active_run.run_id IS NULL THEN NULL
|
||||
ELSE to_jsonb(active_run) - 'project_id' END AS active_run
|
||||
ELSE to_jsonb(active_run) - 'project_id' END AS active_run,
|
||||
CASE WHEN latest_run.run_id IS NULL THEN NULL
|
||||
ELSE to_jsonb(latest_run) - 'project_id' END AS latest_run
|
||||
FROM workbench.operational_tasks AS task
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT run.* FROM workbench.operational_task_runs AS run
|
||||
@@ -254,6 +330,11 @@ class OperationalTaskRepository:
|
||||
AND run.status IN ('queued','claimed','running')
|
||||
ORDER BY run.generation DESC LIMIT 1
|
||||
) AS active_run ON true
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT run.* FROM workbench.operational_task_runs AS run
|
||||
WHERE run.project_id=task.project_id AND run.task_id=task.task_id
|
||||
ORDER BY run.generation DESC LIMIT 1
|
||||
) AS latest_run ON true
|
||||
WHERE task.project_id=%s
|
||||
AND (task.document#>>'{source,deleted_at}') IS NULL
|
||||
AND (%s::text IS NULL OR task.task_type=%s::text)
|
||||
@@ -285,7 +366,7 @@ class OperationalTaskRepository:
|
||||
),
|
||||
)
|
||||
return [
|
||||
{**row["document"], "active_run": row["active_run"]}
|
||||
{**row["document"], "active_run": row["active_run"], "latest_run": row["latest_run"]}
|
||||
for row in await cursor.fetchall()
|
||||
]
|
||||
|
||||
@@ -444,6 +525,38 @@ class OperationalTaskRepository:
|
||||
async def insert_report(self, task_id: str, snapshot: dict) -> None:
|
||||
await self._insert_artifact("operational_task_reports", task_id, snapshot)
|
||||
|
||||
async def get_latest_progress(self, task_id: str, generation: int) -> dict | None:
|
||||
# Read compatibility before the coordinated progress migration is applied.
|
||||
availability = await self.connection.execute(
|
||||
"SELECT to_regclass('workbench.operational_task_progress') AS relation"
|
||||
)
|
||||
if not (await availability.fetchone())["relation"]:
|
||||
return None
|
||||
cursor = await self.connection.execute(
|
||||
"""SELECT progress.document FROM workbench.operational_task_progress AS progress
|
||||
JOIN workbench.operational_task_runs AS run
|
||||
ON run.project_id=progress.project_id AND run.run_id=progress.run_id
|
||||
AND run.task_id=progress.task_id
|
||||
WHERE progress.project_id=%s AND progress.task_id=%s AND run.generation=%s""",
|
||||
(self.project_id, task_id, generation),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["document"] if row else None
|
||||
|
||||
async def save_progress(self, task_id: str, snapshot: dict) -> None:
|
||||
cursor = await self.connection.execute(
|
||||
"""INSERT INTO workbench.operational_task_progress
|
||||
(project_id,task_id,run_id,revision,document,published_at)
|
||||
VALUES (%s,%s,%s,%s,%s,%s)
|
||||
ON CONFLICT(project_id,task_id,run_id) DO UPDATE SET
|
||||
revision=excluded.revision,document=excluded.document,published_at=excluded.published_at
|
||||
WHERE workbench.operational_task_progress.revision=excluded.revision-1""",
|
||||
(self.project_id, task_id, snapshot["run_id"], snapshot["revision"],
|
||||
Jsonb(snapshot), snapshot["published_at"]),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
raise DomainError("VERSION_CONFLICT", "Investigation progress revision changed", status=409)
|
||||
|
||||
async def _insert_artifact(self, table: str, task_id: str, snapshot: dict) -> None:
|
||||
if table not in {"operational_task_plans", "operational_task_reports"}:
|
||||
raise ValueError("unsupported artifact table")
|
||||
|
||||
Reference in New Issue
Block a user