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:
2026-10-19 18:46:58 +08:00
parent 2b6fc5aa52
commit be6317eb48
117 changed files with 12703 additions and 638 deletions
+88
View File
@@ -0,0 +1,88 @@
"""Project scoped private message storage, sharing task transaction locks."""
from psycopg import AsyncConnection
from app.infra.db.postgresql.operational_tasks import OperationalTaskRepository
class AgentMessageRepository(OperationalTaskRepository):
def __init__(self, connection: AsyncConnection, project_id):
super().__init__(connection, project_id)
async def get_message(self, message_id: str) -> dict | None:
cursor = await self.connection.execute(
"SELECT * FROM workbench.agent_messages WHERE project_id=%s AND message_id=%s",
(self.project_id, message_id),
)
row = await cursor.fetchone()
return self._message(row) if row else None
async def get_message_request(self, owner_id: str, session_id: str, request_id: str) -> dict | None:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.agent_messages WHERE project_id=%s
AND source_owner_id=%s AND source_session_id=%s AND request_id=%s""",
(self.project_id, owner_id, session_id, request_id),
)
row = await cursor.fetchone()
return self._message(row) if row else None
@staticmethod
def _message(row) -> dict:
return dict(row)
async def insert_message(self, message: dict) -> None:
keys = [key for key in message if key != "project_id"]
await self.connection.execute(
f"INSERT INTO workbench.agent_messages (project_id,{','.join(keys)}) "
f"VALUES ({','.join(['%s'] * (len(keys) + 1))})",
(self.project_id, *(message[key] for key in keys)),
)
async def list_session_messages(
self, owner_id: str, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT * FROM workbench.agent_messages WHERE project_id=%s
AND conversation_owner_id=%s AND (source_session_id=%s OR
(direction='to_chat' AND target_session_id=%s))
AND (%s::timestamptz IS NULL OR (created_at,message_id)<(%s::timestamptz,%s::uuid))
AND (NOT %s OR status IN ('pending','enqueued'))
ORDER BY created_at DESC,message_id DESC LIMIT %s""",
(self.project_id, owner_id, session_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def list_run_session_messages(
self, session_id: str, limit: int, *, cursor_at=None, cursor_id=None, pending_only=False,
) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT m.* FROM workbench.agent_messages m
JOIN workbench.operational_task_runs r ON r.project_id=m.project_id AND r.run_id=m.run_id
WHERE m.project_id=%s AND r.agent_session_id=%s
AND r.status IN ('claimed','running')
AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)<(%s::timestamptz,%s::uuid))
AND (NOT %s OR (m.direction='to_task' AND m.status IN ('pending','enqueued')))
ORDER BY m.created_at DESC,m.message_id DESC LIMIT %s""",
(self.project_id, session_id, cursor_at, cursor_at, cursor_id, pending_only, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def list_pending_messages(self, limit: int, *, cursor_at=None, cursor_id=None) -> list[dict]:
cursor = await self.connection.execute(
"""SELECT m.* FROM workbench.agent_messages m
JOIN workbench.operational_task_runs r
ON r.project_id=m.project_id AND r.run_id=m.run_id
WHERE m.project_id=%s AND m.status IN ('pending','enqueued')
AND (m.direction='to_chat' OR r.status IN ('claimed','running'))
AND (%s::timestamptz IS NULL OR (m.created_at,m.message_id)>(%s::timestamptz,%s::uuid))
ORDER BY m.created_at,m.message_id LIMIT %s""",
(self.project_id, cursor_at, cursor_at, cursor_id, limit),
)
return [self._message(row) for row in await cursor.fetchall()]
async def update_message_delivery(self, message_id: str, update: dict) -> None:
keys = list(update)
await self.connection.execute(
f"UPDATE workbench.agent_messages SET {','.join(key+'=%s' for key in keys)} "
"WHERE project_id=%s AND message_id=%s",
(*(update[key] for key in keys), self.project_id, message_id),
)
+2 -1
View File
@@ -221,6 +221,7 @@ class AnalysisRepository:
*,
limit: int = 100,
offset: int = 0,
lookahead: bool = False,
) -> list[dict]:
if not 1 <= limit <= 500 or offset < 0:
raise ValueError("analysis result page is out of range")
@@ -235,7 +236,7 @@ class AnalysisRepository:
query += " AND result_type = %s"
params = (run_id, result_type)
query += " ORDER BY created_at, result_id LIMIT %s OFFSET %s"
params += (limit, offset)
params += (limit + int(lookahead), offset)
async with conn.cursor() as cur:
await cur.execute(query, params)
+91
View File
@@ -0,0 +1,91 @@
"""Persistent MCP context and receipt storage in the selected project's BizDB."""
from datetime import datetime, timezone
from psycopg import AsyncConnection
from psycopg.types.json import Jsonb
from app.mcp.models import BoundContext
class McpQueryRepository:
def __init__(self, conn: AsyncConnection, project_id):
self.conn, self.project_id = conn, project_id
async def create(self, context: BoundContext):
async with self.conn.transaction(), self.conn.cursor() as cur:
# Bounded maintenance; expired/revoked handles remain invalid even if cleanup fails.
await cur.execute(
"""DELETE FROM workbench.mcp_query_contexts WHERE context_handle IN
(SELECT context_handle FROM workbench.mcp_query_contexts
WHERE project_id=%s AND (expires_at < now() OR revoked_at IS NOT NULL)
ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED)""",
(self.project_id,),
)
await cur.execute(
"""INSERT INTO workbench.mcp_query_contexts
(context_handle,project_id,subject,client_id,document,expires_at)
VALUES (%s,%s,%s,%s,%s,%s)""",
(
context.context_handle,
self.project_id,
context.subject,
context.client_id,
Jsonb(context.model_dump(mode="json")),
context.expires_at,
),
)
async def get(self, handle: str) -> BoundContext | None:
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""SELECT document,revoked_at FROM workbench.mcp_query_contexts
WHERE context_handle=%s AND project_id=%s""",
(handle, self.project_id),
)
row = await cur.fetchone()
if not row:
return None
document = dict(row["document"])
document["revoked_at"] = row["revoked_at"]
return BoundContext.model_validate(document)
async def revoke(self, handle: str, subject: str, client_id: str):
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""UPDATE workbench.mcp_query_contexts SET revoked_at=COALESCE(revoked_at,%s)
WHERE context_handle=%s AND project_id=%s AND subject=%s AND client_id=%s""",
(
datetime.now(timezone.utc),
handle,
self.project_id,
subject,
client_id,
),
)
async def receipt(self, scope_key: str, call_id: str) -> dict | None:
async with self.conn.transaction(), self.conn.cursor() as cur:
await cur.execute(
"""SELECT parameters,result FROM workbench.mcp_query_receipts
WHERE project_id=%s AND scope_key=%s AND call_id=%s""",
(self.project_id, scope_key, call_id),
)
return await cur.fetchone()
async def save_receipt(
self, scope_key: str, call_id: str, parameters: dict, result: dict
):
# Caller owns the short evidence + receipt transaction.
async with self.conn.cursor() as cur:
await cur.execute(
"""INSERT INTO workbench.mcp_query_receipts
(project_id,scope_key,call_id,parameters,result) VALUES (%s,%s,%s,%s,%s)
ON CONFLICT (project_id,scope_key,call_id) DO NOTHING""",
(self.project_id, scope_key, call_id, Jsonb(parameters), Jsonb(result)),
)
async def lock_receipt(self, scope_key: str, call_id: str):
async with self.conn.cursor() as cur:
await cur.execute(
"SELECT pg_advisory_xact_lock(hashtextextended(%s,0))",
(f"mcp:{self.project_id}:{scope_key}:{call_id}",),
)
+119 -6
View File
@@ -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")
+406
View File
@@ -0,0 +1,406 @@
"""Bounded read primitives over existing project views and time-series tables."""
from datetime import datetime
from typing import Any
from uuid import UUID
from psycopg import AsyncConnection
from psycopg import sql
from psycopg.rows import dict_row
from fastapi import HTTPException
from app.infra.db.postgresql.scada import _SCADA_VIEW_SELECT, _device
from app.infra.db.timescaledb.repositories.realtime import RealtimeRepository
from app.infra.db.timescaledb.repositories.analysis import AnalysisResultsRepository
ELEMENT_VIEWS = {
v: v + "s" for v in ("junction", "pipe", "reservoir", "tank", "pump", "valve")
}
MAX_QUERY_BYTES = 8 * 1024 * 1024
async def read_rows(conn, statement, params=(), *, timeout_ms=30000):
async with conn.transaction(), conn.cursor(row_factory=dict_row) as cur:
await cur.execute(
"SELECT set_config('statement_timeout',%s,true)", (str(timeout_ms),)
)
await cur.execute(statement, params)
return await cur.fetchall()
async def asset_rows(conn, element_type, *, element_id=None, limit=100, offset=0):
view = ELEMENT_VIEWS[element_type]
where = sql.SQL("WHERE v.id=%s") if element_id else sql.SQL("")
params = (element_id,) if element_id else ()
statement = sql.SQL(
"SELECT to_jsonb(v)-'geom' AS record FROM gis.{} v {} ORDER BY v.id LIMIT %s OFFSET %s"
).format(sql.Identifier(view), where)
rows = await read_rows(conn, statement, params + (limit, offset))
return [row["record"] for row in rows]
async def scada_devices(conn, *, device_id=None, limit=100, offset=0):
where = " WHERE id=%s" if device_id else ""
params = (device_id,) if device_id else ()
rows = await read_rows(
conn,
_SCADA_VIEW_SELECT + where + " ORDER BY device_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
return [_device(row) for row in rows]
async def series_rows(
conn,
*,
source,
element_type,
ids,
start=None,
end=None,
time=None,
field=None,
run_id=None,
limit=1000,
offset=0,
end_exclusive=False,
):
node = element_type == "junction"
if source == "scada":
table, id_column, fields = (
"measurements",
"device_id",
{"monitored_value", "cleaned_value"},
)
else:
table, id_column = (
("node_results", "node_id") if node else ("link_results", "link_id")
)
fields = (
(
AnalysisResultsRepository.NODE_FIELDS
if node
else AnalysisResultsRepository.LINK_FIELDS
)
if source == "analysis"
else (
RealtimeRepository.NODE_FIELDS
if node
else RealtimeRepository.LINK_FIELDS
)
)
if field is not None and field not in fields:
raise ValueError("unsupported field for this data source and element type")
columns = (
sql.SQL("{},time,{} AS value").format(
sql.Identifier(id_column), sql.Identifier(field)
)
if field
else sql.SQL("*")
)
filters = []
params = []
if ids:
filters.append(sql.SQL("{}=ANY(%s)").format(sql.Identifier(id_column)))
params.append(ids)
if run_id:
filters.append(sql.SQL("run_id=%s"))
params.append(run_id)
if start is not None:
filters.append(
sql.SQL(
"time >= %s AND time < %s"
if end_exclusive
else "time >= %s AND time <= %s"
)
)
params.extend((start, end))
else:
# Existing point-query semantics use +/- one second.
filters.append(
sql.SQL("time BETWEEN %s-interval '1 second' AND %s+interval '1 second'")
)
params.extend((time, time))
statement = sql.SQL(
"SELECT {} FROM {}.{} WHERE {} ORDER BY time,{} LIMIT %s OFFSET %s"
).format(
columns,
sql.Identifier(source),
sql.Identifier(table),
sql.SQL(" AND ").join(filters),
sql.Identifier(id_column),
)
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def sensor_runs(conn, *, run_id=None, limit=100, offset=0):
where = " AND r.run_id=%s" if run_id else ""
params = (run_id,) if run_id else ()
return await read_rows(
conn,
"""SELECT r.run_id,r.name,r.created_by,r.created_at,r.status,
result.payload FROM analysis.runs r JOIN LATERAL
(SELECT payload FROM analysis.results WHERE run_id=r.run_id
AND result_type='sensor_placement' ORDER BY created_at DESC,result_id DESC LIMIT 1) result ON true
WHERE r.run_type='sensor_placement'"""
+ where
+ " ORDER BY r.created_at DESC,r.run_id LIMIT %s OFFSET %s",
params + (limit, offset),
)
async def history_rows(
conn: AsyncConnection, descriptors: list[dict[str, Any]], *,
start: datetime, end: datetime, run_id: UUID | None, limit: int, offset: int,
) -> list[dict[str, Any]]:
"""Page points across raw, cleaned and model series in one SQL statement."""
parts, params = [], []
for index, item in enumerate(descriptors):
source, field = item["source"], item["field"]
if source == "scada":
table, id_column, fields = "measurements", "device_id", {"monitored_value", "cleaned_value"}
elif source in {"realtime", "analysis"}:
node = item["element_type"] == "junction"
table, id_column = ("node_results", "node_id") if node else ("link_results", "link_id")
fields = {"pressure"} if node else {"flow"}
else:
raise ValueError("unsupported history source")
if field not in fields or (source == "analysis" and run_id is None):
raise ValueError("unsupported history field or missing run")
parts.append(sql.SQL(
"SELECT %s::integer AS series_index,time,{} AS value FROM {}.{} "
"WHERE {}=%s AND time >= %s AND time <= %s{}"
).format(
sql.Identifier(field), sql.Identifier(source), sql.Identifier(table),
sql.Identifier(id_column), sql.SQL(" AND run_id=%s" if source == "analysis" else ""),
))
params.extend((index, item["id"], start, end))
if source == "analysis":
params.append(run_id)
if not parts:
return []
statement = sql.SQL(
"SELECT series_index,time,value FROM ({}) points "
"ORDER BY series_index, time LIMIT %s OFFSET %s"
).format(sql.SQL(" UNION ALL ").join(parts))
return await read_rows(conn, statement, tuple(params) + (limit, offset))
async def evidence_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, evidence_id: str, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
) -> dict[str, Any]:
"""Project the immutable snapshot in PostgreSQL before transferring JSON."""
return await _snapshot_page(conn, stored_sql="""
SELECT document->'data_snapshot' AS snapshot
FROM workbench.operational_task_evidence
WHERE project_id=%s AND task_id=%s AND evidence_id=%s
AND jsonb_typeof(document->'data_snapshot')='object'
""", stored_params=(project_id, task_id, evidence_id), data_path=data_path,
limit=limit, offset=offset, mcp_sources=mcp_sources, full_snapshot=full_snapshot)
async def _snapshot_page(
conn: AsyncConnection, *, stored_sql: str, stored_params: tuple, data_path: list[str],
limit: int, offset: int, mcp_sources: list[str], full_snapshot: bool = False,
max_bytes: int = MAX_QUERY_BYTES,
) -> dict[str, Any]:
"""Shared JSONB projection; callers supply only fixed, scoped SQL sources."""
def descriptor(value, path):
return f"""jsonb_build_object(
'collection_type',jsonb_typeof({value}),
'count',CASE jsonb_typeof({value}) WHEN 'array' THEN jsonb_array_length({value})
WHEN 'string' THEN char_length({value} #>> '{{}}')
ELSE (SELECT count(*) FROM jsonb_object_keys({value})) END,
'data_path',to_jsonb({path}))"""
collection = descriptor("p.value", "p.path")
child = descriptor("child.item", "p.path || ARRAY[child.key]")
statement = """
WITH stored AS MATERIALIZED (
STORED_QUERY
), captured AS (
SELECT snapshot,
snapshot->>'source'=ANY(%s::text[])
AND jsonb_typeof(snapshot->'data')='object' AS is_mcp
FROM stored
), selected AS MATERIALIZED (
SELECT snapshot,
CASE WHEN is_mcp THEN (snapshot->'data')-'data' END AS query_metadata,
(CASE WHEN is_mcp THEN snapshot->'data'->'data'
ELSE snapshot->'data' END) #> %s::text[] AS value
FROM captured
), entries AS (
SELECT ordinality AS position, NULL::text AS key, item AS value
FROM selected, jsonb_array_elements(
CASE WHEN jsonb_typeof(value)='array' THEN value ELSE '[]'::jsonb END
) WITH ORDINALITY AS a(item, ordinality)
UNION ALL
SELECT row_number() OVER (ORDER BY e.key COLLATE "C"), e.key, e.item
FROM selected, jsonb_each(
CASE WHEN jsonb_typeof(value)='object' THEN value ELSE '{}'::jsonb END
) AS e(key,item)
), paged AS MATERIALIZED (
SELECT * FROM entries ORDER BY position LIMIT %s OFFSET %s
), paths AS (
SELECT paged.*, jsonb_typeof(selected.value) AS parent_type,
%s::text[] || ARRAY[CASE WHEN jsonb_typeof(selected.value)='array'
THEN (position-1)::text ELSE key END] AS path
FROM paged,selected
), summaries AS (
SELECT p.position,p.key,p.value AS original,
CASE WHEN jsonb_typeof(p.value) IN ('array','object') OR
(jsonb_typeof(p.value)='string' AND char_length(p.value #>> '{}')>256) THEN
CASE WHEN p.parent_type='object' OR jsonb_typeof(p.value) IN ('array','string')
THEN COLLECTION_PREVIEW
ELSE COALESCE((SELECT jsonb_object_agg(child.key,
CASE WHEN jsonb_typeof(child.item) IN ('array','object') OR
(jsonb_typeof(child.item)='string' AND char_length(child.item #>> '{}')>256)
THEN CHILD_PREVIEW ELSE child.item END)
FROM jsonb_each(p.value) AS child(key,item)), '{}'::jsonb)
END ELSE p.value END AS value
FROM paths p
), projected AS (
SELECT CASE WHEN %s THEN snapshot ELSE jsonb_build_object(
'snapshot_metadata',snapshot-'data', 'query_metadata',query_metadata,
'nested',EXISTS(SELECT 1 FROM summaries WHERE original<>value),
'value_type',jsonb_typeof(value),
'total',CASE jsonb_typeof(value)
WHEN 'array' THEN jsonb_array_length(value)
WHEN 'object' THEN (SELECT count(*) FROM jsonb_object_keys(value))
WHEN 'string' THEN char_length(value #>> '{}') END,
'value',CASE jsonb_typeof(value)
WHEN 'array' THEN COALESCE((SELECT jsonb_agg(value ORDER BY position) FROM summaries),'[]'::jsonb)
WHEN 'object' THEN COALESCE((SELECT jsonb_object_agg(key,value) FROM summaries),'{}'::jsonb)
WHEN 'string' THEN to_jsonb(substring(value #>> '{}' FROM %s FOR %s))
ELSE value END
) END AS payload, value IS NULL AS missing_path
FROM selected
), sized AS MATERIALIZED (
SELECT payload,missing_path,octet_length(payload::text)>%s AS too_large
FROM projected
)
SELECT CASE WHEN NOT too_large THEN payload END AS payload,too_large,missing_path
FROM sized
"""
statement = statement.replace("STORED_QUERY", stored_sql).replace("COLLECTION_PREVIEW", collection).replace("CHILD_PREVIEW", child)
rows = await read_rows(conn, statement, (
*stored_params, mcp_sources, data_path, limit, offset,
data_path, full_snapshot, offset + 1, limit, max_bytes,
))
if not rows:
raise HTTPException(404, "Stored query document was not found")
row = rows[0]
if row["too_large"]:
raise HTTPException(413, "Query exceeds the byte budget; select a deeper data_path or smaller page")
if row.get("missing_path") and not full_snapshot:
raise HTTPException(422, "Evidence data_path does not name a stored value")
return row["payload"]
async def task_context_summary(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, report_revision: int | None) -> dict:
"""Read fixed-size metadata and explicit previews, never a complete report."""
report = """jsonb_build_object('revision',r.revision,
'execution_generation',r.document->'execution_generation',
'stage',r.document#>'{report,stage}','outcome',r.document#>'{report,outcome}',
'current_conclusion_preview',left(r.document#>>'{report,current_conclusion}',200),
'preview_only',true,'detail',jsonb_build_object('section','report','report_revision',r.revision))"""
rows = await read_rows(conn, """
SELECT jsonb_build_object('task',jsonb_build_object(
'task_id',t.task_id,'task_type',t.task_type,'workflow',t.document->>'workflow','status',t.status,
'lifecycle_status',t.lifecycle_status,'priority',t.priority,'revision',t.revision,
'execution_generation',t.execution_generation,
'current_report_revision',t.current_report_revision,
'title_preview',left(t.document->>'title',160),'preview_only',true,
'detail',jsonb_build_object('section','task')),
'latest_report',(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id ORDER BY r.revision DESC LIMIT 1),
'selected_report',CASE WHEN %s::int IS NULL THEN NULL ELSE
(SELECT REPORT_SUMMARY FROM workbench.operational_task_reports r
WHERE r.project_id=t.project_id AND r.task_id=t.task_id AND r.revision=%s) END
) AS payload
FROM workbench.operational_tasks t WHERE t.project_id=%s AND t.task_id=%s
""".replace("REPORT_SUMMARY", report), (report_revision, report_revision, project_id, task_id))
if not rows:
raise HTTPException(404, "Task was not found in this project")
result = rows[0]["payload"]
if report_revision is not None and result["selected_report"] is None:
raise HTTPException(404, "Selected report revision was not found")
return result
async def task_context_page(
conn: AsyncConnection, *, project_id: UUID, task_id: UUID, section: str,
report_revision: int | None, data_path: list[str], limit: int, offset: int,
) -> dict:
"""Page only the requested task section inside PostgreSQL, before transfer."""
collections = {
"evidence": ("operational_task_evidence", "evidence_id"),
"actions": ("operational_task_actions", "action_id"),
"access_requests": ("operational_task_access_requests", "access_request_id"),
}
documents = {"task": "operational_tasks", "report": "operational_task_reports", "plan": "operational_task_plans"}
if section in {"access_requests", "progress"}:
table = "operational_task_access_requests" if section == "access_requests" else "operational_task_progress"
available = await read_rows(conn, "SELECT to_regclass(%s) AS relation", ("workbench." + table,))
if not available[0]["relation"]:
return {"snapshot_metadata": {"section": section, "available": False}, "value": None, "value_type": "null", "total": None, "nested": False}
projection_offset = offset
if section in collections:
table, key = collections[section]
# These identifiers come exclusively from the fixed table above.
relation = sql.Identifier("workbench", table).as_string()
identifier = sql.Identifier(key).as_string()
if data_path:
stored = f"""SELECT jsonb_build_object('section',%s::text,'data',jsonb_build_object({identifier}::text,document)) AS snapshot
FROM {relation} WHERE project_id=%s AND task_id=%s AND {identifier}::text=%s"""
params = (section, project_id, task_id, data_path[0])
else:
# Fetch just this page of rows. Collection keys remain stable across
# paging, so every nested data_path uses the real evidence/action ID.
stored = f"""WITH items AS MATERIALIZED (
SELECT {identifier}::text AS key,document FROM {relation}
WHERE project_id=%s AND task_id=%s
ORDER BY {identifier}::text COLLATE "C" LIMIT %s OFFSET %s)
SELECT jsonb_build_object('section',%s::text,
'total',(SELECT count(*) FROM {relation} WHERE project_id=%s AND task_id=%s),
'data',COALESCE(jsonb_object_agg(key,document),'{{}}'::jsonb)) AS snapshot FROM items"""
params = (project_id, task_id, limit + 1, offset, section, project_id, task_id)
projection_offset = 0
elif section in documents:
relation = sql.Identifier("workbench", documents[section]).as_string()
where = ""
params = (section, project_id, task_id)
if section == "report":
where = " AND (%s::int IS NULL OR revision=%s) ORDER BY revision DESC LIMIT 1"
params += (report_revision, report_revision)
elif section == "plan":
where = " ORDER BY revision DESC LIMIT 1"
stored = f"SELECT jsonb_build_object('section',%s::text,'data',document) AS snapshot FROM {relation} WHERE project_id=%s AND task_id=%s{where}"
elif section == "run":
stored = """SELECT jsonb_build_object('section','run','data',to_jsonb(r)-'project_id') AS snapshot
FROM workbench.operational_task_runs r WHERE project_id=%s AND task_id=%s ORDER BY generation DESC LIMIT 1"""
params = (project_id, task_id)
elif section == "progress":
stored = """SELECT jsonb_build_object('section','progress','data',p.document) AS snapshot
FROM workbench.operational_task_progress p
JOIN workbench.operational_task_runs r ON r.project_id=p.project_id AND r.task_id=p.task_id AND r.run_id=p.run_id
JOIN workbench.operational_tasks t ON t.project_id=p.project_id AND t.task_id=p.task_id AND t.execution_generation=r.generation
WHERE p.project_id=%s AND p.task_id=%s"""
params = (project_id, task_id)
else:
raise HTTPException(422, "Unknown task context section")
return await _snapshot_page(conn, stored_sql=stored, stored_params=params,
data_path=data_path, limit=limit, offset=projection_offset, mcp_sources=[], max_bytes=8000)
async def task_data_scopes(conn: AsyncConnection, *, project_id: UUID, task_id: UUID, permissions: set[str]) -> list[str]:
"""Bound scope discovery to seven known sources, without loading task detail."""
known = ["workbench_runtime", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data", "scada_devices", "scada_readings"]
scopes = {name for name in known if ("scada.view" if name.startswith("scada_") else "simulation.view") in permissions}
available = await read_rows(conn, "SELECT to_regclass('workbench.operational_task_access_requests') AS relation")
if available[0]["relation"]:
rows = await read_rows(conn, """SELECT DISTINCT scope FROM workbench.operational_task_access_requests,
LATERAL jsonb_array_elements_text(COALESCE(document->'approved_scopes','[]'::jsonb)) AS granted(scope)
WHERE project_id=%s AND task_id=%s AND status='approved' AND scope=ANY(%s::text[])""", (project_id, task_id, known))
scopes.update(row["scope"] for row in rows)
return [name for name in known if name in scopes]
+4 -2
View File
@@ -111,14 +111,16 @@ class ScadaInfoRepository:
conn: AsyncConnection,
node_ids: list[str],
link_ids: list[str],
*, row_limit: int | None = None,
) -> list[dict[str, Any]]:
if not node_ids and not link_ids:
return []
async with conn.cursor() as cur:
await cur.execute(
_SCADA_VIEW_SELECT
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id",
(node_ids, link_ids),
+ " WHERE node_id = ANY(%s) OR link_id = ANY(%s) ORDER BY device_id"
+ (" LIMIT %s" if row_limit is not None else ""),
(node_ids, link_ids) + ((row_limit,) if row_limit is not None else ()),
)
return [_device(record) for record in await cur.fetchall()]
@@ -0,0 +1,71 @@
"""Bounded nearest-model queries on the already authorized project connection."""
from math import asin, cos, degrees, radians, sin
from typing import Any
from psycopg import AsyncConnection, sql
from app.infra.db.postgresql.read_queries import ELEMENT_VIEWS, read_rows
_MODEL_RELATIONS = {
kind: sql.Identifier("gis", view) for kind, view in ELEMENT_VIEWS.items()
}
def _search_boxes(
longitude: float, latitude: float, radius_m: float,
) -> list[tuple[float, float, float, float]]:
# A deliberately smaller-than-WGS84 curvature radius gives a conservative
# geographic envelope. It is only a GiST candidate filter; geography below
# determines the actual radius and distances. Split at the antimeridian.
angle = (radius_m + 10) / 6_300_000
low, high = latitude - degrees(angle), latitude + degrees(angle)
if low < -85 or high > 85:
# Avoid Mercator's polar singularity; retain exact geography filtering.
return []
width = degrees(asin(min(1.0, sin(angle) / cos(radians(latitude)))))
left, right = longitude - width, longitude + width
if left < -180:
return [(-180, low, right, high), (left + 360, low, 180, high)]
if right > 180:
return [(left, low, 180, high), (-180, low, right - 360, high)]
return [(left, low, right, high)]
async def nearby_elements(
conn: AsyncConnection,
element_type: str,
*,
center: tuple[float, float],
radius_m: float,
limit: int,
offset: int,
) -> list[dict[str, Any]]:
"""Rank complete model geometries by spheroidal distance, never endpoints."""
relation = _MODEL_RELATIONS[element_type]
filters = []
parameters: list[Any] = [*center]
for box in _search_boxes(*center, radius_m):
filters.append(sql.SQL(
"v.geom && ST_Transform(ST_MakeEnvelope(%s,%s,%s,%s,4326),3857)"
))
parameters.extend(box)
candidate_filter = sql.SQL(" OR ").join(filters) if filters else sql.SQL("TRUE")
statement = sql.SQL("""
WITH target AS (
SELECT ST_SetSRID(ST_MakePoint(%s,%s),4326)::geography AS point
), candidates AS MATERIALIZED (
SELECT v.id, to_jsonb(v)-'geom' AS properties,
ST_Transform(v.geom,4326)::geography AS shape
FROM {} v
WHERE v.geom IS NOT NULL AND NOT ST_IsEmpty(v.geom) AND ({})
)
SELECT c.id AS element_id, c.properties,
ST_Distance(c.shape,t.point,true) AS distance_m
FROM candidates c CROSS JOIN target t
WHERE ST_DWithin(c.shape,t.point,%s,true)
ORDER BY distance_m,c.id
LIMIT %s OFFSET %s
""").format(relation, candidate_filter)
parameters.extend((radius_m, limit, offset))
return await read_rows(conn, statement, tuple(parameters))