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__":