feat(workbench): persist operational workflows and events

This commit is contained in:
2026-09-24 04:19:02 +08:00
parent baff7dfe9e
commit 9335bdc1ea
66 changed files with 16580 additions and 238 deletions
+114
View File
@@ -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))
+130
View File
@@ -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()))
+169
View File
@@ -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()
+259
View File
@@ -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
+84 -4
View File
@@ -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,
+309 -195
View File
@@ -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
+170
View File
@@ -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