feat: add project-scoped operations
Generic Container CI/CD / test-build-publish (push) Failing after 6s
Server CI/CD v2 / build-test-publish-and-deploy (push) Failing after 6s

This commit is contained in:
2026-10-22 18:40:20 +08:00
parent d320972d57
commit 1d061f6099
73 changed files with 4384 additions and 228 deletions
+47 -28
View File
@@ -12,7 +12,7 @@ import json
import logging
import os
import time
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime
from pathlib import Path
from uuid import UUID, uuid4
@@ -37,11 +37,21 @@ class WorkerSettings:
client_secret: str = ""
token_refresh_seconds: float = 300
task_created_from: str | None = None
project_ids: tuple[str, ...] = ()
def project_scopes(self) -> tuple["WorkerSettings", ...]:
"""Keep credentials, sessions and cursors separate for each explicit project."""
return tuple(replace(self, project_id=project_id, project_ids=())
for project_id in self.project_ids or (self.project_id,))
@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"]))
configured_projects = os.environ.get("OPERATIONAL_WORKER_PROJECT_IDS", "").strip()
project_ids = tuple(dict.fromkeys(
str(UUID(value.strip())) for value in configured_projects.split(",")
)) if configured_projects else ()
project_id = project_ids[0] if project_ids else 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")
@@ -60,6 +70,7 @@ class WorkerSettings:
client_secret=os.environ.get("OPERATIONAL_WORKER_CLIENT_SECRET", ""),
token_refresh_seconds=float(os.environ.get("OPERATIONAL_WORKER_TOKEN_REFRESH_SECONDS", "300")),
task_created_from=os.environ.get("OPERATIONAL_WORKER_TASK_CREATED_FROM") or None,
project_ids=project_ids,
)
if settings.task_created_from:
boundary = datetime.fromisoformat(settings.task_created_from.replace("Z", "+00:00"))
@@ -305,37 +316,45 @@ class OperationalWorker:
await asyncio.sleep(5)
async def run(once: bool) -> None:
settings = WorkerSettings.from_environment()
async def run_project(settings: WorkerSettings, client: httpx.AsyncClient, once: bool) -> None:
logger.info(
"Investigation worker scope: project=%s task_created_from=%s concurrency=%d",
settings.project_id, settings.task_created_from or "all", settings.max_concurrency,
)
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
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: project=%s accepted=%d", settings.project_id, accepted)
except (httpx.HTTPError, ValueError, KeyError):
logger.error("Task dispatch tick failed: project=%s; persisted tasks remain available for retry", settings.project_id)
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)
raise
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)
async def run(once: bool) -> None:
settings = WorkerSettings.from_environment()
async with httpx.AsyncClient(timeout=30, follow_redirects=False, trust_env=False) as client:
results = await asyncio.gather(
*(run_project(scope, client, once) for scope in settings.project_scopes()),
return_exceptions=once,
)
if any(isinstance(result, Exception) for result in results):
raise SystemExit(1)
if __name__ == "__main__":
+13 -9
View File
@@ -372,7 +372,7 @@ class PeripheralWorkOrderSynchronizer:
event_name = ""
await asyncio.sleep(1)
except (httpx.HTTPError, ValueError, KeyError):
logger.warning("Work-order event stream disconnected; reconciliation remains active")
logger.warning("Work-order event stream disconnected: project=%s; reconciliation remains active", self.settings.project_id)
await asyncio.sleep(5)
async def serve(self, reconcile_seconds: int = 300) -> None:
@@ -399,12 +399,12 @@ class PeripheralWorkOrderSynchronizer:
self.client, self.settings, self.peripheral_url, self.credentials,
**({"work_order_ids": order_ids} if not full_reconcile else {}),
)
logger.info("Peripheral progress synchronization completed: %d revisions", count)
logger.info("Peripheral progress synchronization completed: project=%s revisions=%d", self.settings.project_id, count)
retry_seconds = 5
if full_reconcile:
next_reconcile = asyncio.get_running_loop().time() + reconcile_seconds
except (httpx.HTTPError, ValueError, KeyError):
logger.error("Peripheral synchronization failed; persisted revisions will be retried")
logger.error("Peripheral synchronization failed: project=%s; persisted revisions will be retried", self.settings.project_id)
self.full_reconcile |= full_reconcile
for order_id in order_ids:
self._queue_order(order_id)
@@ -419,17 +419,21 @@ class PeripheralWorkOrderSynchronizer:
async def run(once: bool, reconcile_seconds: int = 300) -> None:
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:
if once:
try:
count = await sync_once(client, settings, peripheral_url, credentials)
logger.info("Peripheral progress synchronization completed: %d revisions", count)
except (httpx.HTTPError, ValueError, KeyError):
results = await asyncio.gather(*(
sync_once(client, scope, peripheral_url, CredentialProvider(scope))
for scope in settings.project_scopes()
), return_exceptions=True)
if any(isinstance(result, Exception) for result in results):
logger.error("Peripheral synchronization failed; persisted revisions remain available")
raise SystemExit(1) from None
logger.info("Peripheral progress synchronization completed: %d revisions", sum(results))
return
await PeripheralWorkOrderSynchronizer(client, settings, peripheral_url, credentials).serve(reconcile_seconds)
await asyncio.gather(*(
PeripheralWorkOrderSynchronizer(client, scope, peripheral_url, CredentialProvider(scope)).serve(reconcile_seconds)
for scope in settings.project_scopes()
))
if __name__ == "__main__":