feat(workbench): persist operational workflows and events
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Delete expired project workbench events in bounded batches."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from uuid import UUID
|
||||
|
||||
from psycopg import AsyncConnection
|
||||
from psycopg.conninfo import conninfo_to_dict
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPOSITORY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||
|
||||
from app.auth.project_dependencies import DB_ROLE_BIZ_DATA
|
||||
from app.core.config import settings
|
||||
from app.infra.db.metadb.database import SessionLocal, close_metadata_engine
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
|
||||
def _database_identity(dsn: str) -> tuple[str, str, str]:
|
||||
values = conninfo_to_dict(dsn)
|
||||
return (
|
||||
values.get("host") or values.get("hostaddr") or "localhost",
|
||||
values.get("port") or "5432",
|
||||
values.get("dbname") or values.get("user") or "",
|
||||
)
|
||||
|
||||
|
||||
async def cleanup_project(
|
||||
project_id: UUID, dsn: str, retention_days: int, batch_size: int, dry_run: bool
|
||||
) -> int:
|
||||
async with await AsyncConnection.connect(dsn) as conn:
|
||||
if dry_run:
|
||||
cursor = await conn.execute(
|
||||
"""SELECT count(*) FROM workbench.domain_event_log
|
||||
WHERE project_id=%s
|
||||
AND occurred_at < now() - make_interval(days => %s)""",
|
||||
(project_id, retention_days),
|
||||
)
|
||||
return int((await cursor.fetchone())[0])
|
||||
deleted = 0
|
||||
while True:
|
||||
cursor = await conn.execute(
|
||||
"""WITH expired AS (
|
||||
SELECT event_seq FROM workbench.domain_event_log
|
||||
WHERE project_id=%s
|
||||
AND occurred_at < now() - make_interval(days => %s)
|
||||
ORDER BY event_seq LIMIT %s
|
||||
)
|
||||
DELETE FROM workbench.domain_event_log e
|
||||
USING expired WHERE e.event_seq=expired.event_seq""",
|
||||
(project_id, retention_days, batch_size),
|
||||
)
|
||||
await conn.commit()
|
||||
count = cursor.rowcount
|
||||
deleted += count
|
||||
if count < batch_size:
|
||||
return deleted
|
||||
|
||||
|
||||
async def cleanup(
|
||||
retention_days: int, batch_size: int, project_id: UUID | None, dry_run: bool
|
||||
) -> dict[UUID, int]:
|
||||
async with SessionLocal() as session:
|
||||
repository = MetadataRepository(session)
|
||||
routings = await repository.list_active_project_db_routings(DB_ROLE_BIZ_DATA)
|
||||
if project_id is not None:
|
||||
routings = [routing for routing in routings if routing.project_id == project_id]
|
||||
if not routings:
|
||||
raise ValueError("active project business database routing was not found")
|
||||
by_database = defaultdict(list)
|
||||
for routing in routings:
|
||||
by_database[_database_identity(routing.dsn)].append(routing)
|
||||
shared = [items for items in by_database.values() if len(items) > 1]
|
||||
if shared:
|
||||
project_ids = ", ".join(str(item.project_id) for items in shared for item in items)
|
||||
raise ValueError(f"shared business database routing is unsupported: {project_ids}")
|
||||
return {
|
||||
routing.project_id: await cleanup_project(
|
||||
routing.project_id, routing.dsn, retention_days, batch_size, dry_run
|
||||
)
|
||||
for routing in routings
|
||||
}
|
||||
|
||||
|
||||
async def main(args) -> None:
|
||||
try:
|
||||
results = await cleanup(
|
||||
args.retention_days, args.batch_size, args.project_id, args.dry_run
|
||||
)
|
||||
for project_id, count in results.items():
|
||||
action = "would delete" if args.dry_run else "deleted"
|
||||
print(f"{project_id}: {action} {count} events")
|
||||
finally:
|
||||
await close_metadata_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--retention-days", type=int, default=settings.WORKBENCH_EVENT_RETENTION_DAYS
|
||||
)
|
||||
parser.add_argument("--batch-size", type=int, default=5000)
|
||||
parser.add_argument("--project-id", type=UUID)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.retention_days < 1 or args.batch_size < 1:
|
||||
parser.error("retention days and batch size must be positive")
|
||||
asyncio.run(main(args))
|
||||
@@ -0,0 +1,130 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Import one historical synthetic investigation without rewriting Agent history."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import httpx
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.domain.schemas.operational_task import AgentArtifactsCommand
|
||||
|
||||
try:
|
||||
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||
except ModuleNotFoundError:
|
||||
from run_operational_worker import CredentialProvider, WorkerSettings
|
||||
|
||||
|
||||
def convert_plan(snapshot: dict) -> tuple[dict, list[dict], dict]:
|
||||
evidence, nodes = {}, []
|
||||
for node in snapshot["nodes"]:
|
||||
refs = []
|
||||
for text in node.get("evidence", []):
|
||||
evidence_id = "legacy-synthetic:" + hashlib.sha256(text.encode()).hexdigest()[:24]
|
||||
refs.append(evidence_id)
|
||||
evidence[evidence_id] = {
|
||||
"evidence_id": evidence_id, "kind": "synthetic", "summary": text,
|
||||
"source_label": "历史水质投诉合成调查输入", "source_ref": snapshot["session_id"],
|
||||
"observed_at": snapshot["updated_at"],
|
||||
}
|
||||
nodes.append({"id": node["id"], "title": node["title"], "owner": node["owner"],
|
||||
"status": node["status"], "detail": node["detail"], "evidence_refs": refs})
|
||||
plan = {"schema": "execution-plan@1", "title": snapshot["title"],
|
||||
"reason": "从原调查会话导入已保存的计划,未重新执行调查", "nodes": nodes,
|
||||
"edges": [{"from": edge["from"], "to": edge["to"],
|
||||
**({"condition": edge["label"]} if edge.get("label") else {})}
|
||||
for edge in snapshot["edges"]]}
|
||||
judgment = next((node for node in nodes if node["id"] == "STAGE_JUDGMENT"), None)
|
||||
report = {"title": "历史水质投诉调查报告", "current_conclusion": judgment["detail"] if judgment else "历史调查尚无阶段判断",
|
||||
"conclusion_boundary": "合成演练的历史结果,未重新查询当前数据;现场工作以实际反馈为准",
|
||||
"evidence_refs": judgment["evidence_refs"] if judgment else [],
|
||||
"pending_items": [node["detail"] for node in nodes if node["status"] in {"waiting", "pending"}]}
|
||||
return plan, list(evidence.values()), report
|
||||
|
||||
|
||||
async def run(args):
|
||||
if not re.fullmatch(r"ses_[A-Za-z0-9_-]+", args.session_id):
|
||||
raise ValueError("invalid session id")
|
||||
filename = args.session_id.lower() + ".json"
|
||||
metadata = json.loads((args.agent_data / "session-metadata" / filename).read_text())
|
||||
state = json.loads((args.agent_data / "session-ui-states" / filename).read_text())
|
||||
if metadata.get("triggerWorkflow") != "water_quality_complaint" or metadata.get("triggerSource") != "peripheral_sample":
|
||||
raise ValueError("only explicitly synthetic peripheral water investigations can be imported")
|
||||
snapshots = state.get("executionPlans", [])
|
||||
if not snapshots:
|
||||
raise ValueError("session has no saved plan; no progress will be fabricated")
|
||||
snapshot = max(snapshots, key=lambda item: item["revision"])
|
||||
plan, evidence, report = convert_plan(snapshot)
|
||||
# Validate the complete payload before reporting readiness or creating a
|
||||
# backend task. A dry run must catch incompatible historical plan states.
|
||||
artifacts = AgentArtifactsCommand.model_validate({
|
||||
"request_id": str(uuid5(NAMESPACE_URL, f"legacy-artifacts:{metadata.get('projectId')}:{args.session_id}:{snapshot['revision']}")),
|
||||
"source_session_id": args.session_id, "plan": plan, "evidence": evidence, "report": report,
|
||||
}).model_dump(mode="json", by_alias=True)
|
||||
if args.dry_run:
|
||||
print(f"Ready to import {len(plan['nodes'])} nodes and {len(evidence)} synthetic evidence records; no writes")
|
||||
return
|
||||
settings = WorkerSettings.from_environment()
|
||||
if metadata.get("projectId") != settings.project_id:
|
||||
raise ValueError("session does not belong to the worker project")
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||
headers = await CredentialProvider(settings).headers(client)
|
||||
base = settings.server_url.rstrip("/") + "/api/v1/operational-tasks"
|
||||
response = await client.post(base, headers=headers, json={
|
||||
"request_id": str(uuid5(NAMESPACE_URL, f"legacy-investigation:{settings.project_id}:{args.session_id}")),
|
||||
"task_type": "event_investigation", "workflow": "water_quality_complaint",
|
||||
"title": metadata.get("title") or "水质投诉调查", "summary": "从历史合成调查导入,原会话完整保留",
|
||||
"source": {"kind": "external_system", "external_id": metadata["triggerSubjectId"], "event_kind": "water_quality_complaint"},
|
||||
"trigger": {"kind": "event", "occurred_at": metadata["triggerOccurredAt"], "detail": "历史合成外围投诉事件"},
|
||||
})
|
||||
response.raise_for_status()
|
||||
task = response.json()
|
||||
if task["current_plan_revision"] is not None:
|
||||
print("Task already has a plan; preserved without overwrite")
|
||||
return
|
||||
run = task.get("active_run")
|
||||
if not run:
|
||||
raise RuntimeError("task has no active Agent run")
|
||||
if run.get("agent_session_id") != args.session_id or run["status"] != "running":
|
||||
response = await client.post(
|
||||
f"{base}/{task['task_id']}/runs/{run['run_id']}/claim",
|
||||
headers=headers,
|
||||
json={
|
||||
"request_id": run["execution_request_id"],
|
||||
"expected_revision": run["revision"],
|
||||
"source_session_id": args.session_id,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
run = response.json()
|
||||
artifacts["run_id"] = run["run_id"]
|
||||
response = await client.post(f"{base}/{task['task_id']}/agent-artifacts", headers=headers, json=artifacts)
|
||||
response.raise_for_status()
|
||||
run = response.json()["task"]["active_run"]
|
||||
response = await client.post(
|
||||
f"{base}/{task['task_id']}/runs/{run['run_id']}/complete",
|
||||
headers=headers,
|
||||
json={
|
||||
"request_id": str(uuid5(NAMESPACE_URL, f"legacy-complete:{settings.project_id}:{args.session_id}:{snapshot['revision']}")),
|
||||
"expected_revision": run["revision"],
|
||||
"source_session_id": args.session_id,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
print(f"Imported task {task['task_id']}; original Agent records unchanged")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("session_id")
|
||||
parser.add_argument("--agent-data", type=Path, default=Path(__file__).resolve().parents[2] / "next-tjwater-agent/data")
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
asyncio.run(run(parser.parse_args()))
|
||||
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Provision the isolated public Keycloak client used by the 15173 UI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPOSITORY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||
|
||||
from scripts.provision_operational_worker_identity import ( # noqa: E402
|
||||
KeycloakProvisioner,
|
||||
ProvisionError,
|
||||
_request,
|
||||
)
|
||||
|
||||
|
||||
REDIRECT_URIS = [
|
||||
"http://127.0.0.1:15173/*",
|
||||
"http://localhost:15173/*",
|
||||
]
|
||||
WEB_ORIGINS = [
|
||||
"http://127.0.0.1:15173",
|
||||
"http://localhost:15173",
|
||||
]
|
||||
|
||||
|
||||
def ensure_public_client(
|
||||
keycloak: KeycloakProvisioner, client_id: str
|
||||
) -> tuple[bool, dict]:
|
||||
matches = _request(
|
||||
keycloak.client,
|
||||
"GET",
|
||||
f"{keycloak.admin_base}/clients",
|
||||
params={"clientId": client_id},
|
||||
).json()
|
||||
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||
if len(exact) > 1:
|
||||
raise ProvisionError("multiple Keycloak clients use the integration client id")
|
||||
created = not exact
|
||||
if created:
|
||||
_request(
|
||||
keycloak.client,
|
||||
"POST",
|
||||
f"{keycloak.admin_base}/clients",
|
||||
json={
|
||||
"clientId": client_id,
|
||||
"name": "TJWater isolated integration UI",
|
||||
"description": "Local 15173 UI; authorization code flow with PKCE only",
|
||||
"enabled": True,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": True,
|
||||
"bearerOnly": False,
|
||||
"serviceAccountsEnabled": False,
|
||||
"standardFlowEnabled": True,
|
||||
"implicitFlowEnabled": False,
|
||||
"directAccessGrantsEnabled": False,
|
||||
"fullScopeAllowed": False,
|
||||
"redirectUris": REDIRECT_URIS,
|
||||
"webOrigins": WEB_ORIGINS,
|
||||
"attributes": {"pkce.code.challenge.method": "S256"},
|
||||
},
|
||||
)
|
||||
matches = _request(
|
||||
keycloak.client,
|
||||
"GET",
|
||||
f"{keycloak.admin_base}/clients",
|
||||
params={"clientId": client_id},
|
||||
).json()
|
||||
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||
if len(exact) != 1 or not isinstance(exact[0].get("id"), str):
|
||||
raise ProvisionError("could not resolve the integration UI client")
|
||||
internal_id = exact[0]["id"]
|
||||
representation = _request(
|
||||
keycloak.client,
|
||||
"GET",
|
||||
f"{keycloak.admin_base}/clients/{internal_id}",
|
||||
).json()
|
||||
attributes = dict(representation.get("attributes") or {})
|
||||
attributes["pkce.code.challenge.method"] = "S256"
|
||||
representation.update(
|
||||
{
|
||||
"enabled": True,
|
||||
"publicClient": True,
|
||||
"bearerOnly": False,
|
||||
"serviceAccountsEnabled": False,
|
||||
"standardFlowEnabled": True,
|
||||
"implicitFlowEnabled": False,
|
||||
"directAccessGrantsEnabled": False,
|
||||
"fullScopeAllowed": False,
|
||||
"redirectUris": REDIRECT_URIS,
|
||||
"webOrigins": WEB_ORIGINS,
|
||||
"attributes": attributes,
|
||||
}
|
||||
)
|
||||
_request(
|
||||
keycloak.client,
|
||||
"PUT",
|
||||
f"{keycloak.admin_base}/clients/{internal_id}",
|
||||
json=representation,
|
||||
)
|
||||
verified = _request(
|
||||
keycloak.client,
|
||||
"GET",
|
||||
f"{keycloak.admin_base}/clients/{internal_id}",
|
||||
).json()
|
||||
expected = {
|
||||
"enabled": True,
|
||||
"publicClient": True,
|
||||
"bearerOnly": False,
|
||||
"serviceAccountsEnabled": False,
|
||||
"standardFlowEnabled": True,
|
||||
"implicitFlowEnabled": False,
|
||||
"directAccessGrantsEnabled": False,
|
||||
"fullScopeAllowed": False,
|
||||
}
|
||||
if any(verified.get(key) != value for key, value in expected.items()):
|
||||
raise ProvisionError("integration UI client flow settings did not persist")
|
||||
if set(verified.get("redirectUris") or []) != set(REDIRECT_URIS):
|
||||
raise ProvisionError("integration UI redirect URI allowlist did not persist")
|
||||
if set(verified.get("webOrigins") or []) != set(WEB_ORIGINS):
|
||||
raise ProvisionError("integration UI web origin allowlist did not persist")
|
||||
if (verified.get("attributes") or {}).get("pkce.code.challenge.method") != "S256":
|
||||
raise ProvisionError("integration UI PKCE policy did not persist")
|
||||
return created, {
|
||||
"client_id": client_id,
|
||||
"public_client": True,
|
||||
"standard_flow": True,
|
||||
"pkce": "S256",
|
||||
"implicit_flow": False,
|
||||
"direct_grants": False,
|
||||
"service_accounts": False,
|
||||
"redirect_uris": REDIRECT_URIS,
|
||||
"web_origins": WEB_ORIGINS,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088")
|
||||
parser.add_argument("--realm", default="tjwater")
|
||||
parser.add_argument("--admin-username", default="admin")
|
||||
parser.add_argument("--client-id", default="tjwater-integration-ui")
|
||||
args = parser.parse_args()
|
||||
password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass(
|
||||
"Keycloak admin password: "
|
||||
)
|
||||
if not password:
|
||||
raise SystemExit("administrator password is required")
|
||||
try:
|
||||
keycloak = KeycloakProvisioner(
|
||||
args.keycloak_url, args.realm, args.admin_username, password
|
||||
)
|
||||
try:
|
||||
created, result = ensure_public_client(keycloak, args.client_id)
|
||||
finally:
|
||||
keycloak.close()
|
||||
except ProvisionError as exc:
|
||||
raise SystemExit(f"provisioning failed: {exc}") from None
|
||||
print(json.dumps({"created": created, **result}, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,459 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Provision the least-privileged OAuth identity used by operational workers.
|
||||
|
||||
The Keycloak administrator password is read with a hidden prompt or from the
|
||||
process-only KEYCLOAK_ADMIN_PASSWORD environment variable. It is never written
|
||||
to disk. Client secrets are written only to the ignored server .env file.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import base64
|
||||
import getpass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from urllib.parse import urlparse
|
||||
from uuid import UUID
|
||||
|
||||
import httpx
|
||||
from dotenv import dotenv_values, set_key
|
||||
from sqlalchemy import select
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(REPOSITORY_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(REPOSITORY_ROOT))
|
||||
|
||||
from app.auth.permissions import permissions_for_context
|
||||
from app.auth.project_dependencies import ProjectContext
|
||||
from app.infra.db.metadb import models
|
||||
from app.infra.db.metadb.database import SessionLocal, close_metadata_engine
|
||||
from app.infra.db.metadb.repositories.metadata_repository import MetadataRepository
|
||||
|
||||
|
||||
class ProvisionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _request(client: httpx.Client, method: str, url: str, **kwargs) -> httpx.Response:
|
||||
try:
|
||||
response = client.request(method, url, **kwargs)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProvisionError(f"identity provider request failed: {method} {url}") from exc
|
||||
if response.status_code >= 400:
|
||||
raise ProvisionError(
|
||||
f"identity provider rejected request: {method} {url} status={response.status_code}"
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
def _jwt_payload(token: str) -> dict:
|
||||
try:
|
||||
payload = token.split(".")[1]
|
||||
payload += "=" * (-len(payload) % 4)
|
||||
value = json.loads(base64.urlsafe_b64decode(payload))
|
||||
except (IndexError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise ProvisionError("identity provider returned an invalid access token") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise ProvisionError("identity provider returned an invalid access token payload")
|
||||
return value
|
||||
|
||||
|
||||
def _audiences(payload: dict) -> set[str]:
|
||||
audience = payload.get("aud", [])
|
||||
if isinstance(audience, str):
|
||||
return {audience}
|
||||
if isinstance(audience, list):
|
||||
return {item for item in audience if isinstance(item, str)}
|
||||
return set()
|
||||
|
||||
|
||||
class KeycloakProvisioner:
|
||||
def __init__(self, base_url: str, realm: str, admin_username: str, admin_password: str):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.realm = realm
|
||||
self.client = httpx.Client(timeout=15, follow_redirects=False, trust_env=False)
|
||||
token_response = _request(
|
||||
self.client,
|
||||
"POST",
|
||||
f"{self.base_url}/realms/master/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "password",
|
||||
"client_id": "admin-cli",
|
||||
"username": admin_username,
|
||||
"password": admin_password,
|
||||
},
|
||||
)
|
||||
token = token_response.json().get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ProvisionError("administrator authentication returned no access token")
|
||||
self.client.headers["Authorization"] = f"Bearer {token}"
|
||||
self.admin_base = f"{self.base_url}/admin/realms/{realm}"
|
||||
|
||||
def close(self) -> None:
|
||||
self.client.close()
|
||||
|
||||
def ensure_client(self, client_id: str) -> tuple[str, bool]:
|
||||
matches = _request(
|
||||
self.client,
|
||||
"GET",
|
||||
f"{self.admin_base}/clients",
|
||||
params={"clientId": client_id},
|
||||
).json()
|
||||
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||
if len(exact) > 1:
|
||||
raise ProvisionError("multiple Keycloak clients use the requested client id")
|
||||
created = not exact
|
||||
if created:
|
||||
_request(
|
||||
self.client,
|
||||
"POST",
|
||||
f"{self.admin_base}/clients",
|
||||
json={
|
||||
"clientId": client_id,
|
||||
"name": "TJWater operational worker",
|
||||
"description": "Dedicated backend worker identity; no interactive login",
|
||||
"enabled": True,
|
||||
"protocol": "openid-connect",
|
||||
"publicClient": False,
|
||||
"bearerOnly": False,
|
||||
"serviceAccountsEnabled": True,
|
||||
"standardFlowEnabled": False,
|
||||
"implicitFlowEnabled": False,
|
||||
"directAccessGrantsEnabled": False,
|
||||
"fullScopeAllowed": False,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
},
|
||||
)
|
||||
matches = _request(
|
||||
self.client,
|
||||
"GET",
|
||||
f"{self.admin_base}/clients",
|
||||
params={"clientId": client_id},
|
||||
).json()
|
||||
exact = [item for item in matches if item.get("clientId") == client_id]
|
||||
if len(exact) != 1 or not isinstance(exact[0].get("id"), str):
|
||||
raise ProvisionError("could not resolve the dedicated Keycloak client")
|
||||
internal_id = exact[0]["id"]
|
||||
representation = _request(
|
||||
self.client, "GET", f"{self.admin_base}/clients/{internal_id}"
|
||||
).json()
|
||||
desired = {
|
||||
"enabled": True,
|
||||
"publicClient": False,
|
||||
"bearerOnly": False,
|
||||
"serviceAccountsEnabled": True,
|
||||
"standardFlowEnabled": False,
|
||||
"implicitFlowEnabled": False,
|
||||
"directAccessGrantsEnabled": False,
|
||||
"fullScopeAllowed": False,
|
||||
"clientAuthenticatorType": "client-secret",
|
||||
}
|
||||
if any(representation.get(key) != value for key, value in desired.items()):
|
||||
representation.update(desired)
|
||||
_request(
|
||||
self.client,
|
||||
"PUT",
|
||||
f"{self.admin_base}/clients/{internal_id}",
|
||||
json=representation,
|
||||
)
|
||||
return internal_id, created
|
||||
|
||||
def client_secret(self, internal_id: str) -> str:
|
||||
value = _request(
|
||||
self.client,
|
||||
"GET",
|
||||
f"{self.admin_base}/clients/{internal_id}/client-secret",
|
||||
).json().get("value")
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ProvisionError("dedicated client has no usable secret")
|
||||
return value
|
||||
|
||||
def service_account_subject(self, internal_id: str) -> UUID:
|
||||
value = _request(
|
||||
self.client,
|
||||
"GET",
|
||||
f"{self.admin_base}/clients/{internal_id}/service-account-user",
|
||||
).json().get("id")
|
||||
try:
|
||||
return UUID(str(value))
|
||||
except ValueError as exc:
|
||||
raise ProvisionError("service account subject is not a UUID") from exc
|
||||
|
||||
def ensure_audience_mapper(self, internal_id: str, audience: str) -> bool:
|
||||
mappers = _request(
|
||||
self.client,
|
||||
"GET",
|
||||
f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models",
|
||||
).json()
|
||||
for mapper in mappers:
|
||||
config = mapper.get("config") or {}
|
||||
if (
|
||||
mapper.get("protocolMapper") == "oidc-audience-mapper"
|
||||
and config.get("included.client.audience") == audience
|
||||
and config.get("access.token.claim") == "true"
|
||||
):
|
||||
return False
|
||||
_request(
|
||||
self.client,
|
||||
"POST",
|
||||
f"{self.admin_base}/clients/{internal_id}/protocol-mappers/models",
|
||||
json={
|
||||
"name": f"audience-{audience}",
|
||||
"protocol": "openid-connect",
|
||||
"protocolMapper": "oidc-audience-mapper",
|
||||
"consentRequired": False,
|
||||
"config": {
|
||||
"included.client.audience": audience,
|
||||
"id.token.claim": "false",
|
||||
"access.token.claim": "true",
|
||||
"lightweight.claim": "false",
|
||||
},
|
||||
},
|
||||
)
|
||||
return True
|
||||
|
||||
def service_token(self, client_id: str, client_secret: str) -> tuple[str, dict]:
|
||||
response = _request(
|
||||
self.client,
|
||||
"POST",
|
||||
f"{self.base_url}/realms/{self.realm}/protocol/openid-connect/token",
|
||||
data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
},
|
||||
)
|
||||
token = response.json().get("access_token")
|
||||
if not isinstance(token, str) or not token:
|
||||
raise ProvisionError("service account authentication returned no access token")
|
||||
return token, _jwt_payload(token)
|
||||
|
||||
|
||||
async def _bind_product_identity(
|
||||
subject: UUID,
|
||||
*,
|
||||
username: str,
|
||||
email: str,
|
||||
project_id: UUID,
|
||||
project_code: str,
|
||||
) -> tuple[UUID, str, bool, bool]:
|
||||
async with SessionLocal() as session:
|
||||
repo = MetadataRepository(session)
|
||||
project = await repo.get_project_by_id(project_id)
|
||||
if project is None or project.code != project_code or project.status != "active":
|
||||
raise ProvisionError("target product project is missing, mismatched, or inactive")
|
||||
user = await repo.get_user_by_keycloak_id(subject)
|
||||
created_user = user is None
|
||||
if user is None:
|
||||
username_conflict = await repo.get_user_by_username(username)
|
||||
email_result = await session.execute(select(models.User).where(models.User.email == email))
|
||||
if username_conflict is not None or email_result.scalar_one_or_none() is not None:
|
||||
raise ProvisionError("product user name or email is already bound to another identity")
|
||||
user = await repo.upsert_user_from_keycloak(
|
||||
keycloak_id=subject,
|
||||
username=username,
|
||||
email=email,
|
||||
role="service",
|
||||
is_active=True,
|
||||
)
|
||||
elif (
|
||||
user.username == username
|
||||
and user.role == "user"
|
||||
and user.is_active
|
||||
and not user.is_superuser
|
||||
):
|
||||
# Forward migration for identities provisioned before the dedicated
|
||||
# service role existed.
|
||||
user = await repo.upsert_user_from_keycloak(
|
||||
keycloak_id=subject,
|
||||
username=username,
|
||||
email=email,
|
||||
role="service",
|
||||
is_active=True,
|
||||
)
|
||||
elif (
|
||||
user.username != username
|
||||
or user.role != "service"
|
||||
or not user.is_active
|
||||
or user.is_superuser
|
||||
):
|
||||
raise ProvisionError("existing product user has incompatible identity or privileges")
|
||||
membership = await repo.get_project_membership(project_id, user.id)
|
||||
created_membership = membership is None
|
||||
if membership is None:
|
||||
membership = await repo.add_project_member(project_id, user.id, "member")
|
||||
elif membership.project_role != "member":
|
||||
raise ProvisionError("existing project membership has an incompatible role")
|
||||
return user.id, membership.project_role, created_user, created_membership
|
||||
|
||||
|
||||
def _update_worker_environment(
|
||||
env_path: Path,
|
||||
*,
|
||||
token_url: str,
|
||||
client_id: str,
|
||||
client_secret: str,
|
||||
project_id: UUID,
|
||||
) -> None:
|
||||
current = dotenv_values(env_path)
|
||||
if current.get("OPERATIONAL_WORKER_BEARER"):
|
||||
raise ProvisionError("a fixed worker bearer already exists; refusing to overwrite it")
|
||||
values = {
|
||||
"OPERATIONAL_WORKER_TOKEN_URL": token_url,
|
||||
"OPERATIONAL_WORKER_CLIENT_ID": client_id,
|
||||
"OPERATIONAL_WORKER_CLIENT_SECRET": client_secret,
|
||||
"OPERATIONAL_WORKER_PROJECT_ID": str(project_id),
|
||||
"OPERATIONAL_WORKER_SERVER_URL": "http://127.0.0.1:8000",
|
||||
"OPERATIONAL_WORKER_AGENT_URL": "http://127.0.0.1:18787",
|
||||
"OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS": "300",
|
||||
"OPERATIONAL_ACTION_PERIPHERAL_URL": "http://127.0.0.1:8011",
|
||||
"OPERATIONAL_DATA_PERIPHERAL_URL": "http://127.0.0.1:8011",
|
||||
"OPERATIONAL_DATA_SERVER_URL": "http://127.0.0.1:8000",
|
||||
}
|
||||
for key, value in values.items():
|
||||
set_key(str(env_path), key, value, quote_mode="always")
|
||||
env_path.chmod(0o600)
|
||||
|
||||
|
||||
def _verify_product_context(token: str, project_id: UUID) -> dict:
|
||||
try:
|
||||
response = httpx.get(
|
||||
"http://127.0.0.1:8000/api/v1/agent-auth-context",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"X-Project-Id": str(project_id),
|
||||
},
|
||||
timeout=10,
|
||||
follow_redirects=False,
|
||||
trust_env=False,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise ProvisionError("product identity verification request failed") from exc
|
||||
if response.status_code != 200:
|
||||
raise ProvisionError(
|
||||
f"product identity verification was rejected with status={response.status_code}"
|
||||
)
|
||||
value = response.json()
|
||||
if not isinstance(value, dict):
|
||||
raise ProvisionError("product identity verification returned an invalid response")
|
||||
return value
|
||||
|
||||
|
||||
async def provision(args: argparse.Namespace, admin_password: str) -> dict:
|
||||
env_path = Path(args.env_file).resolve()
|
||||
env_values = dotenv_values(env_path)
|
||||
audience = env_values.get("KEYCLOAK_AUDIENCE") or ""
|
||||
if audience != "account":
|
||||
raise ProvisionError("backend audience is not the expected account audience")
|
||||
keycloak = KeycloakProvisioner(
|
||||
args.keycloak_url, args.realm, args.admin_username, admin_password
|
||||
)
|
||||
try:
|
||||
internal_id, created_client = keycloak.ensure_client(args.client_id)
|
||||
secret = keycloak.client_secret(internal_id)
|
||||
subject = keycloak.service_account_subject(internal_id)
|
||||
token, payload = keycloak.service_token(args.client_id, secret)
|
||||
mapper_created = False
|
||||
if audience not in _audiences(payload):
|
||||
mapper_created = keycloak.ensure_audience_mapper(internal_id, audience)
|
||||
token, payload = keycloak.service_token(args.client_id, secret)
|
||||
if audience not in _audiences(payload):
|
||||
raise ProvisionError("service token does not contain the backend audience")
|
||||
if payload.get("sub") != str(subject) or not payload.get("preferred_username"):
|
||||
raise ProvisionError("service token identity claims do not match the service account")
|
||||
realm_management = (payload.get("resource_access") or {}).get("realm-management", {})
|
||||
if realm_management.get("roles"):
|
||||
raise ProvisionError("service account unexpectedly has realm-management roles")
|
||||
product_user_id, project_role, created_user, created_membership = (
|
||||
await _bind_product_identity(
|
||||
subject,
|
||||
username=f"service-account-{args.client_id}",
|
||||
email=f"{args.client_id}@service.local",
|
||||
project_id=UUID(args.project_id),
|
||||
project_code=args.project_code,
|
||||
)
|
||||
)
|
||||
context = ProjectContext(
|
||||
project_id=UUID(args.project_id),
|
||||
project_code=args.project_code,
|
||||
user_id=product_user_id,
|
||||
project_role=project_role,
|
||||
system_role="service",
|
||||
is_superuser=False,
|
||||
)
|
||||
expected_permissions = sorted(permissions_for_context(context))
|
||||
_update_worker_environment(
|
||||
env_path,
|
||||
token_url=(
|
||||
f"{args.keycloak_url.rstrip('/')}/realms/{args.realm}"
|
||||
"/protocol/openid-connect/token"
|
||||
),
|
||||
client_id=args.client_id,
|
||||
client_secret=secret,
|
||||
project_id=UUID(args.project_id),
|
||||
)
|
||||
verified = _verify_product_context(token, UUID(args.project_id))
|
||||
if (
|
||||
verified.get("project_id") != args.project_id
|
||||
or verified.get("network") != args.project_code
|
||||
or verified.get("project_role") != "member"
|
||||
or verified.get("role") != "service"
|
||||
or verified.get("is_superuser") is not False
|
||||
or verified.get("permissions") != expected_permissions
|
||||
):
|
||||
raise ProvisionError("product identity verification returned unexpected authorization")
|
||||
return {
|
||||
"client_created": created_client,
|
||||
"audience_mapper_created": mapper_created,
|
||||
"product_user_created": created_user,
|
||||
"membership_created": created_membership,
|
||||
"project_code": verified["network"],
|
||||
"project_role": verified["project_role"],
|
||||
"system_role": verified["role"],
|
||||
"permissions": verified["permissions"],
|
||||
}
|
||||
finally:
|
||||
keycloak.close()
|
||||
await close_metadata_engine()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--keycloak-url", default="http://192.168.1.114:8088")
|
||||
parser.add_argument(
|
||||
"--allow-insecure-http",
|
||||
action="store_true",
|
||||
help="allow cleartext Keycloak credentials for an explicitly trusted development network",
|
||||
)
|
||||
parser.add_argument("--realm", default="tjwater")
|
||||
parser.add_argument("--admin-username", default="admin")
|
||||
parser.add_argument("--client-id", default="tjwater-operational-worker")
|
||||
parser.add_argument("--project-id", required=True)
|
||||
parser.add_argument("--project-code", default="tjwater_v2")
|
||||
parser.add_argument(
|
||||
"--env-file", default=str(Path(__file__).resolve().parents[1] / ".env")
|
||||
)
|
||||
args = parser.parse_args()
|
||||
keycloak_url = urlparse(args.keycloak_url)
|
||||
if keycloak_url.scheme != "https" and not args.allow_insecure_http:
|
||||
raise SystemExit(
|
||||
"Refusing to send administrator and client credentials over cleartext HTTP; "
|
||||
"use HTTPS or explicitly pass --allow-insecure-http for development"
|
||||
)
|
||||
password = os.environ.get("KEYCLOAK_ADMIN_PASSWORD") or getpass.getpass(
|
||||
"Keycloak admin password: "
|
||||
)
|
||||
if not password:
|
||||
raise SystemExit("administrator password is required")
|
||||
try:
|
||||
result = asyncio.run(provision(args, password))
|
||||
except ProvisionError as exc:
|
||||
raise SystemExit(f"provisioning failed: {exc}") from None
|
||||
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,259 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Hand project-scoped queued investigations to the Agent.
|
||||
|
||||
Credentials are deployment environment values, never task/session payloads.
|
||||
Run with --once for an operator-triggered tick or without it for polling.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import httpx
|
||||
from dotenv import load_dotenv
|
||||
|
||||
|
||||
logger = logging.getLogger("operational-worker")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerSettings:
|
||||
server_url: str
|
||||
agent_url: str
|
||||
project_id: str
|
||||
interval_seconds: float = 30
|
||||
max_concurrency: int = 4
|
||||
bearer: str = ""
|
||||
token_url: str = ""
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
token_refresh_seconds: float = 300
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls) -> "WorkerSettings":
|
||||
load_dotenv(Path(__file__).resolve().parents[1] / ".env", override=False)
|
||||
project_id = str(UUID(os.environ["OPERATIONAL_WORKER_PROJECT_ID"]))
|
||||
interval = float(os.environ.get("OPERATIONAL_WORKER_RECONCILE_SECONDS", "30"))
|
||||
if interval < 1:
|
||||
raise ValueError("worker interval must be at least one second")
|
||||
settings = cls(
|
||||
server_url=os.environ.get("OPERATIONAL_WORKER_SERVER_URL", "http://127.0.0.1:8000"),
|
||||
agent_url=os.environ.get("OPERATIONAL_WORKER_AGENT_URL", "http://127.0.0.1:8787"),
|
||||
project_id=project_id,
|
||||
interval_seconds=interval,
|
||||
max_concurrency=int(os.environ.get("OPERATIONAL_WORKER_MAX_CONCURRENCY", "4")),
|
||||
bearer=os.environ.get("OPERATIONAL_WORKER_BEARER", ""),
|
||||
token_url=os.environ.get("OPERATIONAL_WORKER_TOKEN_URL", ""),
|
||||
client_id=os.environ.get("OPERATIONAL_WORKER_CLIENT_ID", ""),
|
||||
client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""),
|
||||
token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")),
|
||||
)
|
||||
if not 30 <= settings.token_refresh_seconds <= 600:
|
||||
raise ValueError("service token refresh interval must be between 30 and 600 seconds")
|
||||
if not 1 <= settings.max_concurrency <= 32:
|
||||
raise ValueError("worker concurrency must be between 1 and 32")
|
||||
if not settings.bearer and not all((settings.token_url, settings.client_id, settings.client_secret)):
|
||||
raise ValueError("configure a product service credential for the operational worker")
|
||||
return settings
|
||||
|
||||
|
||||
class CredentialProvider:
|
||||
def __init__(self, settings: WorkerSettings):
|
||||
self.settings = settings
|
||||
self._token = ""
|
||||
self._expires = 0.0
|
||||
|
||||
async def headers(self, client: httpx.AsyncClient) -> dict[str, str]:
|
||||
if self.settings.bearer:
|
||||
token = self.settings.bearer.removeprefix("Bearer ")
|
||||
else:
|
||||
if time.monotonic() >= self._expires:
|
||||
response = await client.post(self.settings.token_url, data={
|
||||
"grant_type": "client_credentials", "client_id": self.settings.client_id,
|
||||
"client_secret": self.settings.client_secret,
|
||||
})
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
self._token = payload["access_token"]
|
||||
if not isinstance(self._token, str) or not self._token:
|
||||
raise ValueError("identity provider returned no access token")
|
||||
# Product auth also restricts token age, independently of the
|
||||
# provider's exp. Do not cache a long-lived OAuth token for hours.
|
||||
lifetime = min(float(payload.get("expires_in", 60)), self.settings.token_refresh_seconds)
|
||||
self._expires = time.monotonic() + max(1, lifetime - 15)
|
||||
token = self._token
|
||||
return {"Authorization": f"Bearer {token}", "X-Project-Id": self.settings.project_id}
|
||||
|
||||
|
||||
class OperationalWorker:
|
||||
def __init__(self, settings: WorkerSettings, client: httpx.AsyncClient):
|
||||
self.settings = settings
|
||||
self.client = client
|
||||
self.credentials = CredentialProvider(settings)
|
||||
|
||||
async def tick(self) -> int:
|
||||
headers = await self.credentials.headers(self.client)
|
||||
server = self.settings.server_url.rstrip("/")
|
||||
recovery = await self.client.post(
|
||||
f"{server}/api/v1/operational-tasks/leases/recover", headers=headers, json={"limit": 100},
|
||||
)
|
||||
recovery.raise_for_status()
|
||||
active_run_ids: set[str] = set()
|
||||
for status_value in ("claimed", "running"):
|
||||
response = await self.client.get(
|
||||
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||
params={"status": status_value, "limit": self.settings.max_concurrency},
|
||||
)
|
||||
response.raise_for_status()
|
||||
active_run_ids.update(str(item["run_id"]) for item in response.json()["items"])
|
||||
available = max(0, self.settings.max_concurrency - len(active_run_ids))
|
||||
if available == 0:
|
||||
return 0
|
||||
response = await self.client.get(
|
||||
f"{server}/api/v1/operational-task-runs", headers=headers,
|
||||
params={"status": "queued", "limit": available},
|
||||
)
|
||||
response.raise_for_status()
|
||||
queued = response.json()["items"]
|
||||
|
||||
async def dispatch(run: dict) -> bool:
|
||||
if run.get("status") != "queued":
|
||||
return False
|
||||
task_id = str(UUID(run["task_id"]))
|
||||
run_id = str(UUID(run["run_id"]))
|
||||
request_id = str(UUID(run["execution_request_id"]))
|
||||
try:
|
||||
result = await self.client.post(
|
||||
f"{self.settings.agent_url.rstrip('/')}/api/v1/agent/chat/task-executions",
|
||||
headers=headers,
|
||||
json={
|
||||
"task_id": task_id,
|
||||
"run_id": run_id,
|
||||
"execution_request_id": request_id,
|
||||
},
|
||||
)
|
||||
if result.is_success:
|
||||
payload = result.json()
|
||||
if payload.get("task_id") != task_id or payload.get("run_id") != run_id:
|
||||
raise ValueError("Agent returned a different task run")
|
||||
return True
|
||||
await self._record_dispatch_failure(
|
||||
headers, run, retryable=result.status_code >= 500,
|
||||
code=f"AGENT_HTTP_{result.status_code}",
|
||||
)
|
||||
return False
|
||||
except (httpx.HTTPError, ValueError) as exc:
|
||||
await self._record_dispatch_failure(
|
||||
headers, run, retryable=True,
|
||||
code="AGENT_UNAVAILABLE", detail=type(exc).__name__,
|
||||
)
|
||||
return False
|
||||
|
||||
results = await asyncio.gather(*(dispatch(run) for run in queued))
|
||||
return sum(results)
|
||||
|
||||
async def _record_dispatch_failure(
|
||||
self, headers: dict[str, str], run: dict, *, retryable: bool,
|
||||
code: str, detail: str = "Agent rejected task run dispatch",
|
||||
) -> None:
|
||||
response = await self.client.post(
|
||||
f"{self.settings.server_url.rstrip('/')}/api/v1/operational-tasks/"
|
||||
f"{run['task_id']}/runs/{run['run_id']}/dispatch-failure",
|
||||
headers=headers,
|
||||
json={
|
||||
"request_id": str(uuid4()),
|
||||
"expected_revision": run["revision"],
|
||||
"failure_code": code,
|
||||
"failure_detail": detail,
|
||||
"retryable": retryable,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
async def listen(self, wake: asyncio.Event) -> None:
|
||||
cursor = 0
|
||||
while True:
|
||||
try:
|
||||
headers = await self.credentials.headers(self.client)
|
||||
async with self.client.stream(
|
||||
"GET",
|
||||
f"{self.settings.server_url.rstrip('/')}/api/v1/workbench/events",
|
||||
headers={**headers, "Accept": "text/event-stream"},
|
||||
params={"after": cursor},
|
||||
timeout=None,
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
event_name = ""
|
||||
reset_required = False
|
||||
async for line in response.aiter_lines():
|
||||
if line.startswith("id:"):
|
||||
cursor = int(line[3:].strip())
|
||||
elif line.startswith("event:"):
|
||||
event_name = line[6:].strip()
|
||||
elif line.startswith("data:") and event_name == "reset_required":
|
||||
snapshot = await self.client.get(
|
||||
f"{self.settings.server_url.rstrip('/')}/api/v1/workbench/sync-snapshot",
|
||||
headers=headers,
|
||||
)
|
||||
snapshot.raise_for_status()
|
||||
cursor = int(snapshot.json()["event_cursor"])
|
||||
reset_required = True
|
||||
break
|
||||
elif line.startswith("data:") and event_name == "workbench_event":
|
||||
payload = json.loads(line[5:].strip())
|
||||
if payload.get("topic") == "operational.run.queued":
|
||||
wake.set()
|
||||
elif line == "":
|
||||
event_name = ""
|
||||
if not reset_required:
|
||||
await asyncio.sleep(1)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
logger.warning("Workbench event stream disconnected; reconciliation remains active")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
|
||||
async def run(once: bool) -> None:
|
||||
settings = WorkerSettings.from_environment()
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||
worker = OperationalWorker(settings, client)
|
||||
wake = asyncio.Event()
|
||||
listener = None if once else asyncio.create_task(worker.listen(wake))
|
||||
try:
|
||||
while True:
|
||||
try:
|
||||
accepted = await worker.tick()
|
||||
logger.info("Task dispatch tick completed: %d accepted", accepted)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
# Do not log request headers, response bodies or provider secrets.
|
||||
logger.error("Task dispatch tick failed; persisted tasks remain available for retry")
|
||||
if once:
|
||||
raise SystemExit(1) from None
|
||||
if once:
|
||||
return
|
||||
try:
|
||||
await asyncio.wait_for(wake.wait(), timeout=settings.interval_seconds)
|
||||
except TimeoutError:
|
||||
pass
|
||||
wake.clear()
|
||||
finally:
|
||||
if listener is not None:
|
||||
listener.cancel()
|
||||
await asyncio.gather(listener, return_exceptions=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
try:
|
||||
asyncio.run(run(args.once))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
@@ -75,6 +75,56 @@ TEST_DATA = (
|
||||
)
|
||||
|
||||
|
||||
def previous_seed_result(
|
||||
store: PostgresStore,
|
||||
project_id: str,
|
||||
*,
|
||||
seed_request_id: UUID,
|
||||
command_key: str,
|
||||
) -> dict | None:
|
||||
"""Read this script's earlier receipt without weakening API idempotency checks."""
|
||||
with store.connection(readonly=True) as connection:
|
||||
row = connection.execute(
|
||||
"""SELECT command_key,result
|
||||
FROM peripheral_sample.requests
|
||||
WHERE project_id=%s AND actor_id=%s AND request_id=%s""",
|
||||
(project_id, str(ACTOR_ID), seed_request_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if row["command_key"] != command_key:
|
||||
raise RuntimeError("seed request id is already used by another command")
|
||||
return row["result"]
|
||||
|
||||
|
||||
def restore_seeded_resources(
|
||||
store: PostgresStore,
|
||||
project_id: str,
|
||||
*,
|
||||
table: str,
|
||||
id_column: str,
|
||||
resource_ids: list[str],
|
||||
) -> None:
|
||||
"""Restore deterministic seed rows that were soft-deleted after an earlier run."""
|
||||
allowed_resources = {
|
||||
("work_orders", "work_order_id"),
|
||||
("test_data_records", "record_id"),
|
||||
}
|
||||
if (table, id_column) not in allowed_resources:
|
||||
raise ValueError("unsupported seeded resource")
|
||||
if not resource_ids:
|
||||
return
|
||||
|
||||
with store.connection() as connection:
|
||||
connection.execute(
|
||||
f"""UPDATE peripheral_sample.{table}
|
||||
SET document=document - 'deleted_at' - 'deleted_by',
|
||||
deleted_at=NULL, deleted_by=NULL
|
||||
WHERE project_id=%s AND {id_column}=ANY(%s::uuid[])""",
|
||||
(project_id, resource_ids),
|
||||
)
|
||||
|
||||
|
||||
def ensure_demo_dataset(store: PostgresStore, project_id: str) -> str:
|
||||
content = build_demo_content()
|
||||
content_digest = digest(content)
|
||||
@@ -112,8 +162,9 @@ def main() -> int:
|
||||
dataset_digest = ensure_demo_dataset(store, project_id)
|
||||
order_ids = []
|
||||
for item in WORK_ORDERS:
|
||||
seed_request_id = request_id(item["key"])
|
||||
command = CreateWorkOrder(
|
||||
request_id=request_id(item["key"]),
|
||||
request_id=seed_request_id,
|
||||
title=item["title"],
|
||||
summary=item["summary"],
|
||||
status=item["status"],
|
||||
@@ -121,12 +172,27 @@ def main() -> int:
|
||||
assignee=item["assignee"],
|
||||
scheduled_at=item["scheduled_at"],
|
||||
)
|
||||
order_ids.append(store.create_work_order(who, command)["work_order_id"])
|
||||
previous = previous_seed_result(
|
||||
store,
|
||||
project_id,
|
||||
seed_request_id=seed_request_id,
|
||||
command_key="create_work_order",
|
||||
)
|
||||
result = previous or store.create_work_order(who, command)
|
||||
order_ids.append(result["work_order_id"])
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
project_id,
|
||||
table="work_orders",
|
||||
id_column="work_order_id",
|
||||
resource_ids=order_ids,
|
||||
)
|
||||
|
||||
record_ids = []
|
||||
for point_id, label, observed_at, value, unit, quality in TEST_DATA:
|
||||
seed_request_id = request_id(f"test-data/{point_id}/{observed_at}")
|
||||
command = CreateTestDataRecord(
|
||||
request_id=request_id(f"test-data/{point_id}/{observed_at}"),
|
||||
request_id=seed_request_id,
|
||||
label=label,
|
||||
point_id=point_id,
|
||||
observed_at=observed_at,
|
||||
@@ -135,7 +201,21 @@ def main() -> int:
|
||||
quality=quality,
|
||||
notes="外围样板固定合成数据,不是现场监测记录。",
|
||||
)
|
||||
record_ids.append(store.create_test_data(who, command)["record_id"])
|
||||
previous = previous_seed_result(
|
||||
store,
|
||||
project_id,
|
||||
seed_request_id=seed_request_id,
|
||||
command_key="create_test_data",
|
||||
)
|
||||
result = previous or store.create_test_data(who, command)
|
||||
record_ids.append(result["record_id"])
|
||||
restore_seeded_resources(
|
||||
store,
|
||||
project_id,
|
||||
table="test_data_records",
|
||||
id_column="record_id",
|
||||
resource_ids=record_ids,
|
||||
)
|
||||
|
||||
result = {
|
||||
"project_id": project_id,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Project peripheral demo orders into the backend workbench order store."""
|
||||
"""Idempotently seed the workbench's original demo reservation tasks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from psycopg.types.json import Jsonb
|
||||
@@ -21,158 +21,311 @@ from app.native.wndb.core.connection import project_connection
|
||||
|
||||
SCHEMA_SQL = ROOT / "resources/sql/011_workbench_work_orders.sql"
|
||||
SHANGHAI = ZoneInfo("Asia/Shanghai")
|
||||
PROJECTION_SOURCE = "合成数据预约工单"
|
||||
DEMO_WORK_ORDER_ID = "demo-work-order-peripheral-inspection"
|
||||
DATASET_ID = "workbench-demo-orders@1"
|
||||
DEMO_DATE = datetime(2026, 9, 17, tzinfo=SHANGHAI)
|
||||
SEEDED_WORK_ORDER_PREFIX = "mock-work-order-"
|
||||
LEGACY_WORK_ORDER_SOURCES = (
|
||||
"合成数据预约工单",
|
||||
"外围样板预约工单",
|
||||
)
|
||||
|
||||
ORDER_DETAILS = {
|
||||
"合成通信核查": {
|
||||
"location": "DEMO-P001",
|
||||
"duration_minutes": 30,
|
||||
"reply_requirements": ["记录通信恢复时间", "确认缺口数据是否补传"],
|
||||
WORK_ORDER_TEMPLATES = (
|
||||
{
|
||||
"id": "incident-valve-dispatch",
|
||||
"code": "DEMO-WO-20260917-001",
|
||||
"title": "演示工单:华山路阀门边界确认",
|
||||
"summary": "异常事件分析后生成阀门边界确认任务,派发前需调度员复核影响范围。",
|
||||
"detail": "该条目为工作台演示数据,用于展示人工确认界面,不代表真实提交或岗位通知。",
|
||||
"recommendation": "确认影响用户、上下游压力和应急隔离边界后,再通知执行岗位到场。",
|
||||
"source": "异常事件分析",
|
||||
"location": "华山路沿线阀门组 VG-11",
|
||||
"dispatcher": "调度中心值班长",
|
||||
"assignee": "调度执行岗",
|
||||
"priority": "urgent",
|
||||
"risk_level": "attention",
|
||||
"phase": "dispatch",
|
||||
"start_hour": 10,
|
||||
"start_minute": 30,
|
||||
"duration_minutes": 40,
|
||||
"dispatch_lead_minutes": 30,
|
||||
"reply_window_minutes": 20,
|
||||
"reply_requirements": [
|
||||
"确认阀门当前开度",
|
||||
"记录影响分区和用户范围",
|
||||
"回传派发确认截图",
|
||||
],
|
||||
},
|
||||
"合成压力偏差复核": {
|
||||
"location": "DEMO-P001 / DEMO-P002",
|
||||
{
|
||||
"id": "valve-operation-execution",
|
||||
"code": "DEMO-WO-20260917-002",
|
||||
"title": "演示工单:北辰分区阀门开度调整",
|
||||
"summary": "根据管网智能调度方案,现场调整北辰分区边界阀门开度并复核压力。",
|
||||
"detail": "该工单模拟系统派发后的执行阶段,班组正在按方案对阀门要素进行操作。",
|
||||
"recommendation": "执行过程中保持调度语音联络,调整后立即复核末端压力和流量变化。",
|
||||
"source": "调度方案确认",
|
||||
"location": "北辰分区边界阀门 BV-07",
|
||||
"dispatcher": "管网调度岗",
|
||||
"assignee": "北辰抢修二组",
|
||||
"priority": "urgent",
|
||||
"risk_level": "attention",
|
||||
"phase": "execution",
|
||||
"start_hour": 11,
|
||||
"start_minute": 0,
|
||||
"duration_minutes": 55,
|
||||
"dispatch_lead_minutes": 25,
|
||||
"reply_window_minutes": 20,
|
||||
"reply_requirements": [
|
||||
"上传阀门操作前后照片",
|
||||
"记录最终开度",
|
||||
"复核北辰末端压力不低于 0.22MPa",
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "pump-boundary-reply",
|
||||
"code": "DEMO-WO-20260917-003",
|
||||
"title": "演示工单:泵站边界复核复令",
|
||||
"summary": "泵站出水边界复核已完成,等待班组复令和调度确认闭环。",
|
||||
"detail": "该工单模拟执行完成后的复令阶段,用于检查处置结果、照片和指标回传。",
|
||||
"recommendation": "核对泵站出水压力、频率曲线和分区边界模拟结果后完成复令。",
|
||||
"source": "模型复核建议",
|
||||
"location": "北辰二级泵站 P-02",
|
||||
"dispatcher": "模型复核岗",
|
||||
"assignee": "泵站运行班组",
|
||||
"priority": "normal",
|
||||
"risk_level": "normal",
|
||||
"phase": "reply",
|
||||
"start_hour": 13,
|
||||
"start_minute": 0,
|
||||
"duration_minutes": 45,
|
||||
"reply_requirements": ["回填相邻测点对照结果", "确认合成样本复核结论"],
|
||||
"dispatch_lead_minutes": 20,
|
||||
"reply_window_minutes": 35,
|
||||
"reply_requirements": [
|
||||
"上传泵站运行截图",
|
||||
"填写出水压力复核结果",
|
||||
"确认模型边界已同步更新",
|
||||
],
|
||||
},
|
||||
"合成零流量样本确认": {
|
||||
"location": "DEMO-F001",
|
||||
"duration_minutes": 20,
|
||||
"reply_requirements": ["确认零值显示", "确认缺测值显示"],
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def _time_label(value: datetime) -> str:
|
||||
def _clock(value: datetime) -> str:
|
||||
return value.astimezone(SHANGHAI).strftime("%H:%M")
|
||||
|
||||
|
||||
def _time_range(start: datetime, end: datetime) -> str:
|
||||
return f"{_time_label(start)}-{_time_label(end)}"
|
||||
def _clock_range(start: datetime, duration_minutes: int) -> str:
|
||||
return f"{_clock(start)}-{_clock(start + timedelta(minutes=duration_minutes))}"
|
||||
|
||||
|
||||
def _subtask_status(status: str) -> str:
|
||||
mapped = {
|
||||
"open": "pending",
|
||||
"in_progress": "running",
|
||||
"completed": "completed",
|
||||
}.get(status)
|
||||
if mapped is None:
|
||||
raise ValueError(f"unsupported peripheral work-order status: {status}")
|
||||
return mapped
|
||||
|
||||
|
||||
def _stage_statuses(status: str) -> tuple[str, str, str]:
|
||||
if status == "completed":
|
||||
def _stage_statuses(phase: str, position: str) -> tuple[str, str, str]:
|
||||
if position == "before":
|
||||
return "done", "done", "done"
|
||||
if status == "running":
|
||||
if position == "after":
|
||||
return "pending", "pending", "pending"
|
||||
if phase == "dispatch":
|
||||
return "current", "pending", "pending"
|
||||
if phase == "execution":
|
||||
return "done", "current", "pending"
|
||||
return "current", "pending", "pending"
|
||||
return "done", "done", "current"
|
||||
|
||||
|
||||
def project_peripheral_subtask(document: dict) -> dict:
|
||||
status = _subtask_status(document.get("status"))
|
||||
scheduled_at = _parse_timestamp(document["scheduled_at"])
|
||||
details = ORDER_DETAILS.get(document["title"], {})
|
||||
duration_minutes = int(details.get("duration_minutes", 30))
|
||||
execution_end = scheduled_at + timedelta(minutes=duration_minutes)
|
||||
reply_end = execution_end + timedelta(minutes=15)
|
||||
dispatch_status, execution_status, reply_status = _stage_statuses(status)
|
||||
assignee = document.get("assignee") or "待分派班组"
|
||||
def _subtask_status(phase: str, position: str) -> str:
|
||||
if position == "before":
|
||||
return "completed"
|
||||
if position == "after" or phase == "dispatch":
|
||||
return "pending"
|
||||
return "running"
|
||||
|
||||
return {
|
||||
"id": document["work_order_id"],
|
||||
"title": document["title"],
|
||||
"summary": document["summary"],
|
||||
"status": status,
|
||||
"scheduled_at": document["scheduled_at"],
|
||||
"location": details.get("location", "演示点位"),
|
||||
"assignee": assignee,
|
||||
"duration_minutes": duration_minutes,
|
||||
"reply_window_minutes": 15,
|
||||
"stages": [
|
||||
|
||||
def _build_subtasks(template: dict, scheduled_at: datetime) -> list[dict]:
|
||||
requirements = template["reply_requirements"]
|
||||
phase = template["phase"]
|
||||
active_index = {
|
||||
"dispatch": 0,
|
||||
"execution": min(1, len(requirements) - 1),
|
||||
"reply": len(requirements) - 1,
|
||||
}[phase]
|
||||
subtask_duration = max(
|
||||
5,
|
||||
(template["duration_minutes"] + len(requirements) - 1) // len(requirements),
|
||||
)
|
||||
subtasks = []
|
||||
|
||||
for index, requirement in enumerate(requirements):
|
||||
position = (
|
||||
"before"
|
||||
if index < active_index
|
||||
else "after"
|
||||
if index > active_index
|
||||
else "active"
|
||||
)
|
||||
subtask_start = scheduled_at + timedelta(minutes=index * subtask_duration)
|
||||
execution_end = subtask_start + timedelta(minutes=subtask_duration)
|
||||
reply_end = execution_end + timedelta(minutes=template["reply_window_minutes"])
|
||||
dispatch_status, execution_status, reply_status = _stage_statuses(
|
||||
phase, position
|
||||
)
|
||||
subtasks.append(
|
||||
{
|
||||
"id": "dispatch",
|
||||
"sequence": 1,
|
||||
"title": "派发",
|
||||
"status": dispatch_status,
|
||||
"owner": "演示调度",
|
||||
"time_label": _time_label(scheduled_at - timedelta(minutes=15)),
|
||||
"description": "预约工单子任务已进入演示调度流程。",
|
||||
},
|
||||
{
|
||||
"id": "execution",
|
||||
"sequence": 2,
|
||||
"title": "执行",
|
||||
"status": execution_status,
|
||||
"owner": assignee,
|
||||
"time_label": _time_range(scheduled_at, execution_end),
|
||||
"description": "按外围合成场景执行本项核查。",
|
||||
},
|
||||
{
|
||||
"id": "reply",
|
||||
"sequence": 3,
|
||||
"title": "复令",
|
||||
"status": reply_status,
|
||||
"owner": assignee,
|
||||
"time_label": _time_range(execution_end, reply_end),
|
||||
"description": "回传本项核查结果并完成子任务闭环。",
|
||||
},
|
||||
],
|
||||
"reply_requirements": details.get(
|
||||
"reply_requirements", ["回传演示核查结果"]
|
||||
"id": f"{template['id']}-subtask-{index + 1}",
|
||||
"title": requirement,
|
||||
"summary": f"完成“{requirement}”并保留可复核的阶段记录。",
|
||||
"status": _subtask_status(phase, position),
|
||||
"scheduled_at": subtask_start,
|
||||
"location": template["location"],
|
||||
"assignee": template["assignee"],
|
||||
"duration_minutes": subtask_duration,
|
||||
"reply_window_minutes": template["reply_window_minutes"],
|
||||
"stages": [
|
||||
{
|
||||
"id": "dispatch",
|
||||
"sequence": 1,
|
||||
"title": "派发",
|
||||
"status": dispatch_status,
|
||||
"owner": template["dispatcher"],
|
||||
"time_label": _clock(
|
||||
subtask_start
|
||||
- timedelta(minutes=template["dispatch_lead_minutes"])
|
||||
),
|
||||
"description": (
|
||||
f"确认{template['location']}任务范围、优先级和到场窗口,"
|
||||
f"派发给{template['assignee']}。"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "execution",
|
||||
"sequence": 2,
|
||||
"title": "执行中",
|
||||
"status": execution_status,
|
||||
"owner": template["assignee"],
|
||||
"time_label": _clock_range(subtask_start, subtask_duration),
|
||||
"description": template["summary"],
|
||||
},
|
||||
{
|
||||
"id": "reply",
|
||||
"sequence": 3,
|
||||
"title": "待复令",
|
||||
"status": reply_status,
|
||||
"owner": f"{template['assignee']} → 调度中心",
|
||||
"time_label": _clock_range(
|
||||
execution_end,
|
||||
template["reply_window_minutes"],
|
||||
),
|
||||
"description": (
|
||||
"提交处置结果、现场照片和影响复核,"
|
||||
f"最迟 {_clock(reply_end)} 前完成复令。"
|
||||
),
|
||||
},
|
||||
],
|
||||
"reply_requirements": [f"回传“{requirement}”的执行结果"],
|
||||
}
|
||||
)
|
||||
|
||||
return subtasks
|
||||
|
||||
|
||||
def build_demo_work_orders() -> list[dict]:
|
||||
"""Build the three reservation tasks previously provided by the frontend mock."""
|
||||
documents = []
|
||||
for template in WORK_ORDER_TEMPLATES:
|
||||
scheduled_at = DEMO_DATE.replace(
|
||||
hour=template["start_hour"],
|
||||
minute=template["start_minute"],
|
||||
)
|
||||
status = {
|
||||
"dispatch": "pending",
|
||||
"execution": "running",
|
||||
"reply": "awaiting_reply",
|
||||
}[template["phase"]]
|
||||
updated_at = {
|
||||
"dispatch": scheduled_at - timedelta(minutes=5),
|
||||
"execution": scheduled_at + timedelta(minutes=20),
|
||||
"reply": scheduled_at + timedelta(minutes=template["duration_minutes"]),
|
||||
}[template["phase"]]
|
||||
projected = {
|
||||
"id": f"{SEEDED_WORK_ORDER_PREFIX}{template['id']}",
|
||||
"code": template["code"],
|
||||
"scheduled_at": scheduled_at,
|
||||
"title": template["title"],
|
||||
"summary": template["summary"],
|
||||
"status": status,
|
||||
"risk_level": template["risk_level"],
|
||||
"updated_at": updated_at,
|
||||
"detail": template["detail"],
|
||||
"recommendation": template["recommendation"],
|
||||
"duration_minutes": template["duration_minutes"],
|
||||
"source": template["source"],
|
||||
"dispatcher": template["dispatcher"],
|
||||
"priority": template["priority"],
|
||||
"subtasks": _build_subtasks(template, scheduled_at),
|
||||
}
|
||||
documents.append(
|
||||
WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
|
||||
)
|
||||
return documents
|
||||
|
||||
|
||||
def replace_demo_work_orders(conn, project_id: UUID, documents: list[dict]) -> None:
|
||||
"""Remove the obsolete synthetic projection and replace this seed dataset."""
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM workbench.work_orders
|
||||
WHERE project_id = %s
|
||||
AND (
|
||||
work_order_id LIKE 'demo-work-order-%%'
|
||||
OR work_order_id LIKE %s
|
||||
OR document->>'source' = ANY(%s)
|
||||
)
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
f"{SEEDED_WORK_ORDER_PREFIX}%",
|
||||
list(LEGACY_WORK_ORDER_SOURCES),
|
||||
),
|
||||
}
|
||||
)
|
||||
for document in documents:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO workbench.work_orders
|
||||
(project_id, work_order_id, scheduled_at, updated_at, document)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (project_id, work_order_id) DO UPDATE
|
||||
SET scheduled_at = EXCLUDED.scheduled_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
document = EXCLUDED.document
|
||||
""",
|
||||
(
|
||||
project_id,
|
||||
document["id"],
|
||||
document["scheduled_at"],
|
||||
document["updated_at"],
|
||||
Jsonb(document),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def project_peripheral_work_order(source_documents: list[dict]) -> dict:
|
||||
if not source_documents:
|
||||
raise ValueError("source documents must not be empty")
|
||||
|
||||
subtasks = [project_peripheral_subtask(document) for document in source_documents]
|
||||
scheduled_values = [_parse_timestamp(item["scheduled_at"]) for item in source_documents]
|
||||
updated_values = [_parse_timestamp(item["updated_at"]) for item in source_documents]
|
||||
end_values = [
|
||||
scheduled + timedelta(minutes=subtask["duration_minutes"])
|
||||
for scheduled, subtask in zip(scheduled_values, subtasks, strict=True)
|
||||
]
|
||||
statuses = {subtask["status"] for subtask in subtasks}
|
||||
if statuses == {"completed"}:
|
||||
status = "completed"
|
||||
elif statuses == {"pending"}:
|
||||
status = "pending"
|
||||
else:
|
||||
status = "running"
|
||||
priority = "urgent" if any(
|
||||
document.get("priority") in {"high", "urgent"}
|
||||
for document in source_documents
|
||||
) else "normal"
|
||||
|
||||
projected = {
|
||||
"id": DEMO_WORK_ORDER_ID,
|
||||
"code": "DEMO-WO-001",
|
||||
"scheduled_at": min(scheduled_values),
|
||||
"title": "外围合成数据核查",
|
||||
"summary": "依次核查合成通信、压力偏差和零流量样本。",
|
||||
"status": status,
|
||||
"risk_level": "attention" if priority == "urgent" else "normal",
|
||||
"updated_at": max(updated_values),
|
||||
"detail": "外围样板中的三项 synthetic 核查已组合为一张预约工单,仅用于演示。",
|
||||
"recommendation": "按子任务逐项核查并分别完成阶段回传。",
|
||||
"duration_minutes": max(
|
||||
1,
|
||||
int((max(end_values) - min(scheduled_values)).total_seconds() // 60),
|
||||
def publish_demo_work_order_batch_event(conn, project_id: UUID, count: int) -> None:
|
||||
"""Invalidate the demo projection once, never once per seeded row."""
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO workbench.domain_event_log
|
||||
(project_id,event_id,topic,aggregate_type,aggregate_id,
|
||||
aggregate_revision,occurred_at,metadata)
|
||||
SELECT %s,%s,'workbench.work_order.changed','demo_work_order_dataset',%s,
|
||||
COALESCE(max(aggregate_revision),0)+1,now(),%s
|
||||
FROM workbench.domain_event_log
|
||||
WHERE project_id=%s AND aggregate_type='demo_work_order_dataset'
|
||||
AND aggregate_id=%s
|
||||
RETURNING event_seq""",
|
||||
(
|
||||
project_id, uuid4(), DATASET_ID,
|
||||
Jsonb({"namespace": "demo-work-order", "count": count}),
|
||||
project_id, DATASET_ID,
|
||||
),
|
||||
"source": PROJECTION_SOURCE,
|
||||
"dispatcher": "演示调度",
|
||||
"priority": priority,
|
||||
"subtasks": subtasks,
|
||||
}
|
||||
return WorkbenchRuntimeWorkOrder.model_validate(projected).model_dump(mode="json")
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
conn.execute(
|
||||
"SELECT pg_notify('workbench_events', %s)",
|
||||
(f'{project_id}:{row["event_seq"]}',),
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
@@ -180,73 +333,34 @@ def main() -> int:
|
||||
parser.add_argument("--database-name", default="tjwater_v2")
|
||||
parser.add_argument("--project-id", required=True, type=UUID)
|
||||
args = parser.parse_args()
|
||||
documents = build_demo_work_orders()
|
||||
|
||||
with project_connection(args.database_name) as conn, conn.transaction():
|
||||
conn.execute(SCHEMA_SQL.read_text(encoding="utf-8"))
|
||||
source_rows = conn.execute(
|
||||
"""
|
||||
SELECT document
|
||||
FROM peripheral_sample.work_orders
|
||||
WHERE project_id = %s
|
||||
AND deleted_at IS NULL
|
||||
AND document->>'origin' = 'synthetic'
|
||||
AND document->>'scheduled_at' IS NOT NULL
|
||||
AND document->>'status' IN ('open', 'in_progress', 'completed')
|
||||
ORDER BY (document->>'scheduled_at')::timestamptz, work_order_id
|
||||
""",
|
||||
(args.project_id,),
|
||||
).fetchall()
|
||||
source_documents = [row["document"] for row in source_rows]
|
||||
if not source_documents:
|
||||
raise RuntimeError("no eligible peripheral demo work orders found")
|
||||
documents = [project_peripheral_work_order(source_documents)]
|
||||
replace_demo_work_orders(conn, args.project_id, documents)
|
||||
publish_demo_work_order_batch_event(conn, args.project_id, len(documents))
|
||||
|
||||
conn.execute(
|
||||
"""
|
||||
DELETE FROM workbench.work_orders
|
||||
WHERE project_id = %s
|
||||
AND (
|
||||
work_order_id LIKE 'demo-work-order-%%'
|
||||
OR document->>'source' = %s
|
||||
OR document->>'source' = '外围样板预约工单'
|
||||
)
|
||||
""",
|
||||
(args.project_id, PROJECTION_SOURCE),
|
||||
)
|
||||
for document in documents:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO workbench.work_orders
|
||||
(project_id, work_order_id, scheduled_at, updated_at, document)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
ON CONFLICT (project_id, work_order_id) DO UPDATE
|
||||
SET scheduled_at = EXCLUDED.scheduled_at,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
document = EXCLUDED.document
|
||||
""",
|
||||
(
|
||||
args.project_id,
|
||||
document["id"],
|
||||
document["scheduled_at"],
|
||||
document["updated_at"],
|
||||
Jsonb(document),
|
||||
),
|
||||
)
|
||||
|
||||
print(json.dumps({
|
||||
"database": args.database_name,
|
||||
"project_id": str(args.project_id),
|
||||
"source": PROJECTION_SOURCE,
|
||||
"work_orders": [
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"id": document["id"],
|
||||
"code": document["code"],
|
||||
"title": document["title"],
|
||||
"subtask_count": len(document["subtasks"]),
|
||||
}
|
||||
for document in documents
|
||||
],
|
||||
}, ensure_ascii=False, indent=2))
|
||||
"database": args.database_name,
|
||||
"project_id": str(args.project_id),
|
||||
"dataset_id": DATASET_ID,
|
||||
"work_orders": [
|
||||
{
|
||||
"id": document["id"],
|
||||
"code": document["code"],
|
||||
"title": document["title"],
|
||||
"status": document["status"],
|
||||
"subtask_count": len(document["subtasks"]),
|
||||
}
|
||||
for document in documents
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Synchronize the explicitly synthetic peripheral work orders into task projections."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from uuid import NAMESPACE_URL, uuid5
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
from scripts.run_operational_worker import CredentialProvider, WorkerSettings
|
||||
except ModuleNotFoundError:
|
||||
from run_operational_worker import CredentialProvider, WorkerSettings
|
||||
|
||||
|
||||
logger = logging.getLogger("peripheral-task-sync")
|
||||
STATUS = {"draft": "scheduled", "open": "assigned", "in_progress": "in_progress",
|
||||
"awaiting_reply": "awaiting_feedback", "completed": "completed", "cancelled": "cancelled"}
|
||||
|
||||
|
||||
def work_order_snapshot(order: dict) -> dict:
|
||||
"""Preserve external process facts without deriving stages from total status."""
|
||||
required = (
|
||||
"work_order_id", "revision", "title", "summary", "status", "priority",
|
||||
"origin", "updated_at",
|
||||
)
|
||||
snapshot = {key: order[key] for key in required}
|
||||
snapshot.update({key: order.get(key) for key in (
|
||||
"assignee", "scheduled_at", "location", "scenario_code", "subtasks",
|
||||
)})
|
||||
snapshot["work_order_type"] = order.get("work_order_type", "general")
|
||||
return snapshot
|
||||
|
||||
|
||||
async def sync_once(client: httpx.AsyncClient, settings: WorkerSettings, peripheral_url: str,
|
||||
credentials: CredentialProvider) -> int:
|
||||
headers = await credentials.headers(client)
|
||||
tasks_url = f"{settings.server_url.rstrip('/')}/api/v1/operational-tasks"
|
||||
existing, event_tasks, investigations, offset = {}, {}, [], 0
|
||||
while True:
|
||||
response = await client.get(tasks_url, headers=headers, params={"limit": 100, "offset": offset})
|
||||
response.raise_for_status()
|
||||
page = response.json()
|
||||
for task in page["items"]:
|
||||
if task["task_type"] == "external_work_order":
|
||||
existing[task["source"].get("external_id")] = task
|
||||
elif task["task_type"] == "event_investigation":
|
||||
event_tasks[task.get("source", {}).get("external_id")] = task
|
||||
if task["status"] in {"queued", "waiting_human", "running"}:
|
||||
investigations.append(task)
|
||||
if page.get("next_offset") is None:
|
||||
break
|
||||
offset = page["next_offset"]
|
||||
|
||||
linked: dict[str, list[str]] = {}
|
||||
for task in investigations:
|
||||
response = await client.get(f"{tasks_url}/{task['task_id']}", headers=headers)
|
||||
response.raise_for_status()
|
||||
for action in response.json().get("actions", []):
|
||||
receipt = action.get("receipt")
|
||||
if action["kind"] == "create_work_order" and receipt and receipt["adapter"] == "peripheral_sample":
|
||||
linked.setdefault(receipt["receipt_id"], []).append(task["task_id"])
|
||||
|
||||
cursor, synchronized = None, 0
|
||||
while True:
|
||||
params = {"limit": 100}
|
||||
if cursor:
|
||||
params["cursor"] = cursor
|
||||
response = await client.get(f"{peripheral_url.rstrip('/')}/api/sample/v1/work-orders",
|
||||
headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
page = response.json()
|
||||
for order in page["items"]:
|
||||
if order["project_id"] != settings.project_id or order["origin"] != "synthetic":
|
||||
raise ValueError("peripheral work order scope or origin mismatch")
|
||||
external_id = f"peripheral_sample:{order['work_order_id']}"
|
||||
# Complaint intake is an event even if no browser was open when the
|
||||
# external system created it. Use the same source key as the UI.
|
||||
event_kind = order.get("work_order_type")
|
||||
if (
|
||||
event_kind in {"water_quality_complaint", "burst_report"}
|
||||
and order["work_order_id"] not in event_tasks
|
||||
and order["work_order_id"] not in linked
|
||||
):
|
||||
event_request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:investigation"))
|
||||
event = await client.post(tasks_url, headers=headers, json={
|
||||
"request_id": event_request_id, "task_type": "event_investigation", "workflow": event_kind,
|
||||
"title": "水质投诉调查" if event_kind == "water_quality_complaint" else "爆管事件调查", "summary": f"合成外围事件:{order['summary']}",
|
||||
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
|
||||
"source": {"kind": "external_system", "external_id": order["work_order_id"],
|
||||
"external_revision": order["revision"], "event_kind": event_kind},
|
||||
"trigger": {"kind": "event", "occurred_at": order.get("scheduled_at") or order["created_at"],
|
||||
"detail": f"外围样板事件上报 · {order.get('location') or '位置待确认'}"},
|
||||
})
|
||||
event.raise_for_status()
|
||||
event_tasks[order["work_order_id"]] = event.json()
|
||||
task = existing.get(external_id)
|
||||
if task is None:
|
||||
request_id = str(uuid5(NAMESPACE_URL, f"{settings.project_id}:{external_id}:create"))
|
||||
result = await client.post(tasks_url, headers=headers, json={
|
||||
"request_id": request_id, "task_type": "external_work_order", "workflow": "external-work-order",
|
||||
"title": order["title"], "summary": f"合成外围工单:{order['summary']}",
|
||||
"priority": "urgent" if order["priority"] in {"high", "urgent"} else "normal",
|
||||
"source": {"kind": "external_system", "external_id": external_id,
|
||||
"event_kind": order.get("work_order_type", "general")},
|
||||
"trigger": {"kind": "external_sync", "occurred_at": order["created_at"],
|
||||
"detail": "外围样板工单同步,进度以外部系统反馈为准"},
|
||||
"scheduled_for": order["scheduled_at"],
|
||||
})
|
||||
result.raise_for_status()
|
||||
task = result.json()
|
||||
existing[external_id] = task
|
||||
event_id = f"{external_id}:{order['revision']}"
|
||||
feedback = {
|
||||
"request_id": str(uuid5(NAMESPACE_URL, event_id)), "source_event_id": external_id,
|
||||
"source_revision": order["revision"], "status": STATUS[order["status"]],
|
||||
"occurred_at": order["updated_at"],
|
||||
"detail": f"合成外围工单 {order['work_order_id']}:{order['status']}",
|
||||
"evidence": {"evidence_id": event_id, "kind": "synthetic", "summary": order["summary"],
|
||||
"source_label": "外围样板合成工单", "source_ref": external_id,
|
||||
"observed_at": order["updated_at"]},
|
||||
}
|
||||
targets = set(linked.get(order["work_order_id"], []))
|
||||
event_task = event_tasks.get(order["work_order_id"])
|
||||
if event_task and order["revision"] > (event_task["source"].get("external_revision") or 0):
|
||||
targets.add(event_task["task_id"])
|
||||
if (task["source"].get("external_revision") or 0) < order["revision"]:
|
||||
targets.add(task["task_id"])
|
||||
for target in targets:
|
||||
payload = dict(feedback)
|
||||
if target == task["task_id"]:
|
||||
payload["work_order_snapshot"] = work_order_snapshot(order)
|
||||
result = await client.post(f"{tasks_url}/{target}/external-feedback", headers=headers, json=payload)
|
||||
result.raise_for_status()
|
||||
synchronized += 1
|
||||
cursor = page.get("next_cursor")
|
||||
if not cursor:
|
||||
return synchronized
|
||||
|
||||
|
||||
async def run(once: bool):
|
||||
settings = WorkerSettings.from_environment()
|
||||
peripheral_url = os.environ["OPERATIONAL_ACTION_PERIPHERAL_URL"]
|
||||
credentials = CredentialProvider(settings)
|
||||
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
|
||||
while True:
|
||||
try:
|
||||
count = await sync_once(client, settings, peripheral_url, credentials)
|
||||
logger.info("Peripheral progress synchronization completed: %d revisions", count)
|
||||
except (httpx.HTTPError, ValueError, KeyError):
|
||||
logger.error("Peripheral synchronization failed; next tick will reconcile persisted revisions")
|
||||
if once:
|
||||
raise SystemExit(1) from None
|
||||
if once:
|
||||
return
|
||||
await asyncio.sleep(settings.interval_seconds)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--once", action="store_true")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
try:
|
||||
asyncio.run(run(args.once))
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
Reference in New Issue
Block a user