نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی

مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت.
تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
mjalmoudy94
2026-10-13 00:39:28 +03:30
commit 717e53a7ce
185 changed files with 39910 additions and 0 deletions
+29
View File
@@ -0,0 +1,29 @@
"""Standalone workers.
Each worker is a thin driver over the application services. They are shipped as
modules inside the API package so they reuse the exact same composition
(``app.application.container``); ``services/*`` are the process-level shells
that launch them, ready to be split into separate deployments.
Run one with::
python -m app.workers billing
python -m app.workers ai-runtime
python -m app.workers sandbox-gateway
"""
from __future__ import annotations
from app.workers.ai_runtime import AiRuntimeWorker
from app.workers.base import WorkerOutcome, run_periodically, worker_session
from app.workers.billing import BillingWorker
from app.workers.sandbox_gateway import SandboxGatewayWorker
__all__ = [
"AiRuntimeWorker",
"BillingWorker",
"SandboxGatewayWorker",
"WorkerOutcome",
"run_periodically",
"worker_session",
]
+132
View File
@@ -0,0 +1,132 @@
"""CLI entry point for the workers.
python -m app.workers billing [--loop --interval 3600]
python -m app.workers ai-runtime [--loop --interval 30 --limit 10]
python -m app.workers sandbox-gateway [--loop --interval 600 --idle-minutes 120]
Default is **one pass and exit**, which is the shape a cron entry wants. Pass
``--loop`` for a long-running deployment; SIGINT/SIGTERM then stop it cleanly.
"""
from __future__ import annotations
import argparse
import asyncio
import logging
import sys
from app.workers.base import WorkerOutcome, install_signal_handlers, run_periodically
logger = logging.getLogger("agy.worker")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="app.workers", description="AGY background workers")
parser.add_argument(
"worker",
choices=["billing", "ai-runtime", "sandbox-gateway"],
help="which worker to run",
)
parser.add_argument("--loop", action="store_true", help="keep running instead of one pass")
parser.add_argument(
"--interval",
type=float,
default=300.0,
help="seconds between passes when --loop is set (default: 300)",
)
parser.add_argument("--limit", type=int, default=10, help="max rows per pass (default: 10)")
parser.add_argument(
"--idle-minutes",
type=int,
default=120,
help="sandbox-gateway: stop sessions idle for this long (default: 120)",
)
parser.add_argument(
"--period-days",
type=int,
default=30,
help="billing: length of the billing period (default: 30)",
)
return parser
def _billing_pass(args: argparse.Namespace):
from datetime import timedelta
from app.domain.base import utcnow
from app.workers.billing import BillingWorker
worker = BillingWorker()
async def one_pass() -> WorkerOutcome:
end = utcnow()
return await worker.roll_up(
period_start=end - timedelta(days=max(1, args.period_days)),
period_end=end,
)
return one_pass
def _ai_runtime_pass(args: argparse.Namespace):
from app.workers.ai_runtime import AiRuntimeWorker
worker = AiRuntimeWorker()
async def one_pass() -> WorkerOutcome:
return await worker.drain(limit=args.limit)
return one_pass
def _sandbox_gateway_pass(args: argparse.Namespace):
from app.workers.sandbox_gateway import SandboxGatewayWorker
gateway = SandboxGatewayWorker()
async def one_pass() -> WorkerOutcome:
return await gateway.reap(
idle_minutes=args.idle_minutes, limit=max(args.limit, 200)
)
return one_pass
def _pass_function(args: argparse.Namespace):
"""Return a zero-argument coroutine function for one pass."""
if args.worker == "billing":
return _billing_pass(args)
if args.worker == "ai-runtime":
return _ai_runtime_pass(args)
return _sandbox_gateway_pass(args)
async def _main_async(args: argparse.Namespace) -> int:
one_pass = _pass_function(args)
stop = asyncio.Event()
if args.loop:
install_signal_handlers(stop)
outcomes = await run_periodically(
one_pass,
interval_seconds=max(1.0, args.interval),
stop=stop,
run_once=not args.loop,
worker_name=args.worker,
)
failed = sum(o.failed for o in outcomes)
return 1 if failed else 0
def main(argv: list[str] | None = None) -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",
)
args = _build_parser().parse_args(argv)
return asyncio.run(_main_async(args))
if __name__ == "__main__":
sys.exit(main())
+94
View File
@@ -0,0 +1,94 @@
"""AI runtime worker — drives queued tasks through the run engine.
The HTTP API can start a run inline (that is what the "اجرا" button does), but
a platform that only ever runs work on the request thread has two problems: a
slow model call holds a connection open, and a task created by anything other
than a button press never runs at all. This worker is the second driver.
It deliberately owns **no** orchestration logic. It finds runnable tasks and
hands each one to ``WorkforceService.start_run`` — the same call the router
makes. Model routing, usage accounting, the timeline and the artifact are all
the service's business.
"""
from __future__ import annotations
from app.application.container import build_workforce_service
from app.core.errors import AppError
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.domain.workforce.models import TaskStatus
from app.infrastructure.error_log import record_error
from app.infrastructure.repositories.workforce import TaskRepository
from app.workers.base import WorkerOutcome, worker_session
class AiRuntimeWorker:
"""Drains the queue of tasks that nobody has run yet."""
name = "ai-runtime"
async def drain(self, *, limit: int = 10) -> WorkerOutcome:
"""Run up to ``limit`` queued tasks, oldest first.
A task that fails is recorded and the pass continues: one poisoned task
must not block the queue behind it.
"""
outcome = WorkerOutcome(name=self.name)
async with worker_session() as session:
tasks = TaskRepository(session)
workforce = build_workforce_service(session)
for task in await tasks.list_runnable(limit=limit):
# Re-read through the service so the tenant check and the
# "already closed" guard both apply. The worker gets no
# privileged bypass.
try:
current = await workforce.get_task_or_404(
organization_id=task.organization_id, task_id=task.id
)
except AppError:
outcome.skipped += 1
outcome.note(f"task {task.id}: vanished before the run")
continue
if current.status not in (TaskStatus.NEW, TaskStatus.TRIAGED):
outcome.skipped += 1
continue
try:
run = await workforce.start_run(
organization_id=task.organization_id,
task_id=task.id,
user_id=task.created_by_user_id or "",
employee_id=task.assigned_employee_id,
)
except Exception as exc: # noqa: BLE001 - keep draining
outcome.failed += 1
outcome.note(f"task {task.id}: {type(exc).__name__}: {exc}")
# Persist with the tenant attached: a task that fails to run
# is a tenant-visible problem, and the console must be able
# to show that organization what happened.
await record_error(
kind="task_run_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.WORKER,
severity=ErrorSeverity.ERROR,
organization_id=task.organization_id,
user_id=task.created_by_user_id,
metadata={
"worker": self.name,
"task_id": task.id,
"employee_id": task.assigned_employee_id,
},
)
continue
outcome.processed += 1
outcome.note(f"task {task.id} -> run {run.id} ({run.status})")
return outcome
__all__ = ["AiRuntimeWorker"]
+166
View File
@@ -0,0 +1,166 @@
"""Shared plumbing for the standalone workers.
The workers are deliberately thin. Every one of them is a *driver* that calls
the same application services the HTTP API calls — the wiring comes from
``app.application.container``, so a worker and the API can never disagree about
which adapter backs a port. No business rule lives in this package; if a worker
and the API ever disagreed, the API would be right.
That is also what makes the ``services/*`` split cheap later: moving a worker
into its own process changes the process boundary, not the code.
"""
from __future__ import annotations
import asyncio
import logging
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
from dataclasses import dataclass, field
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import SessionFactory
logger = logging.getLogger("agy.worker")
@dataclass(slots=True)
class WorkerOutcome:
"""What one pass of a worker did.
Returned rather than only logged, so a test can assert on it and an
operator can see a number instead of grepping a log file.
"""
name: str
processed: int = 0
skipped: int = 0
failed: int = 0
notes: list[str] = field(default_factory=list)
#: Keep the note list bounded: a worker that processes 10k rows must not
#: accumulate 10k strings in memory just to print a summary.
MAX_NOTES = 50
def note(self, message: str) -> None:
if len(self.notes) < self.MAX_NOTES:
self.notes.append(message)
@property
def ok(self) -> bool:
return self.failed == 0
@property
def touched(self) -> int:
return self.processed + self.skipped + self.failed
def summary(self) -> str:
return (
f"{self.name}: processed={self.processed} "
f"skipped={self.skipped} failed={self.failed}"
)
@asynccontextmanager
async def worker_session() -> AsyncIterator[AsyncSession]:
"""One committed transaction per pass; rolled back whole on failure.
A worker pass is all-or-nothing on purpose: a half-applied invoice rollup
is worse than no rollup, because the next pass would skip the period it
thinks it already billed.
"""
async with SessionFactory() as session:
try:
yield session
await session.commit()
except Exception:
await session.rollback()
raise
async def run_periodically(
pass_fn: Callable[[], Awaitable[WorkerOutcome]],
*,
interval_seconds: float,
stop: asyncio.Event,
run_once: bool = False,
worker_name: str | None = None,
) -> list[WorkerOutcome]:
"""Drive ``pass_fn`` until ``stop`` is set — or exactly once.
``run_once=True`` is the cron/scheduled shape (what the CLI uses by
default). The loop shape exists for long-running deployments.
A failing pass is persisted before the loop continues. A worker that only
logged to stderr would lose every failure the moment the container was
replaced — which is precisely when someone wants to know what went wrong.
"""
outcomes: list[WorkerOutcome] = []
name: str = worker_name or str(getattr(pass_fn, "__name__", "worker"))
while True:
try:
outcome = await pass_fn()
outcomes.append(outcome)
logger.info(outcome.summary())
for note in outcome.notes:
logger.info("%s | %s", outcome.name, note)
except Exception as exc: # noqa: BLE001 - a worker must survive a bad pass
logger.exception("%s: pass failed", name)
await _record_pass_failure(name, exc)
outcomes.append(
WorkerOutcome(name=name, failed=1, notes=[f"pass failed: {type(exc).__name__}"])
)
if run_once:
return outcomes
try:
# Returns early (cleanly) when ``stop`` is set during the wait.
await asyncio.wait_for(stop.wait(), timeout=interval_seconds)
return outcomes
except TimeoutError:
continue
async def _record_pass_failure(worker_name: str, exc: BaseException) -> None:
"""Persist a failed pass. Imported lazily to keep this module import-light."""
from app.domain.audit.models import ErrorSeverity, ErrorSource
from app.infrastructure.error_log import record_error
await record_error(
kind="worker_pass_failed",
message=str(exc) or type(exc).__name__,
exc=exc,
source=ErrorSource.WORKER,
severity=ErrorSeverity.ERROR,
metadata={"worker": worker_name},
)
def install_signal_handlers(stop: asyncio.Event) -> None:
"""Ask the event loop to set ``stop`` on SIGINT/SIGTERM.
Best-effort: on a platform without these signals (or outside the main
thread) the worker simply runs until interrupted some other way.
"""
loop = asyncio.get_running_loop()
for signal_name in ("SIGINT", "SIGTERM"):
try:
import signal as _signal
sig = getattr(_signal, signal_name)
except (AttributeError, ValueError):
continue
try:
loop.add_signal_handler(sig, stop.set)
except (NotImplementedError, RuntimeError, ValueError):
continue
__all__ = [
"WorkerOutcome",
"install_signal_handlers",
"run_periodically",
"worker_session",
]
+133
View File
@@ -0,0 +1,133 @@
"""Billing worker — rolls the usage ledger into draft invoices.
The usage ledger is the source of truth for billing; an invoice is a *snapshot*
of one closed period. That ordering matters: the worker never invents an amount
the ledger does not already contain, and it never talks to a payment provider —
moving money is the API's job (``BillingService.settle_payment``), not a
background job's.
The one hard requirement is **idempotency**. A cron job that runs twice for the
same period must not bill twice, so a period that already has an invoice is
skipped rather than re-created.
"""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from app.domain.base import utcnow
from app.domain.billing.models import Invoice, InvoiceStatus
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import InvoiceRepository
from app.infrastructure.repositories.identity import OrganizationRepository
from app.workers.base import WorkerOutcome, worker_session
#: A period shorter than this is almost certainly a bug (a stray manual run),
#: not a real billing cycle. Refusing it keeps the invoice table meaningful.
MIN_PERIOD = timedelta(hours=1)
class BillingWorker:
"""Turns usage into invoices, once per period, per organization."""
name = "billing-worker"
async def roll_up(
self,
*,
period_start: datetime | None = None,
period_end: datetime | None = None,
organization_id: str | None = None,
) -> WorkerOutcome:
"""Create a DRAFT invoice for every organization with usage in the period.
``organization_id`` narrows the pass to one tenant (useful for a
backfill or a support-driven re-run).
"""
end = period_end or utcnow()
start = period_start or (end - timedelta(days=30))
if end <= start:
raise ValueError("period_end must be after period_start")
if end - start < MIN_PERIOD:
raise ValueError(
f"billing period must be at least {MIN_PERIOD} long"
)
outcome = WorkerOutcome(name=self.name)
async with worker_session() as session:
invoices = InvoiceRepository(session)
ledger = UsageLedgerRepository(session)
organizations = OrganizationRepository(session)
if organization_id is not None:
targets = [organization_id]
else:
targets = [org.id for org in await organizations.list_all()]
for org_id in targets:
existing = await invoices.list_for_org(org_id)
if any(self._covers(i, start, end) for i in existing):
outcome.skipped += 1
continue
breakdown = await ledger.breakdown(
organization_id=org_id, since=start, until=end
)
# Six decimals, not two: a single small model call costs a
# fraction of a cent, and rounding to cents here would silently
# turn real usage into a zero invoice.
amount = round(breakdown.total_price, 6)
if amount <= 0:
# No billable usage: do not litter the table with $0 drafts.
outcome.skipped += 1
outcome.note(f"{org_id}: no billable usage in this period")
continue
invoice = await invoices.create(
Invoice(
organization_id=org_id,
amount=amount,
status=InvoiceStatus.DRAFT,
period_start=start,
period_end=end,
)
)
outcome.processed += 1
outcome.note(f"{org_id}: {invoice.id} amount={amount}")
return outcome
@staticmethod
def _naive_utc(value: datetime) -> datetime:
"""Normalise a timestamp to naive UTC for comparison.
SQLite drops ``tzinfo`` on round-trip while Postgres keeps it, so
comparing a freshly computed aware timestamp against one read back from
the database raises ``TypeError`` on one backend and works on the other.
Comparing in a single explicit frame avoids depending on which database
the deployment happens to use.
"""
if value.tzinfo is None:
return value
return value.astimezone(UTC).replace(tzinfo=None)
@classmethod
def _covers(cls, invoice: Invoice, start: datetime, end: datetime) -> bool:
"""Is this period already invoiced?
Any invoice whose period *overlaps* the requested one counts. Overlap
rather than exact equality, because two runs a few seconds apart would
otherwise produce two near-identical invoices for the same window.
"""
if invoice.period_start is None or invoice.period_end is None:
return False
window_start, window_end = cls._naive_utc(start), cls._naive_utc(end)
return (
cls._naive_utc(invoice.period_start) < window_end
and window_start < cls._naive_utc(invoice.period_end)
)
__all__ = ["MIN_PERIOD", "BillingWorker"]
+94
View File
@@ -0,0 +1,94 @@
"""Sandbox gateway worker — housekeeping for the only sanctioned execution path.
Two jobs, both about not leaking resources:
1. **Reap idle sessions.** A sandbox session that nobody has touched for hours
is still consuming CPU and disk on the provider. The reaper stops it and
writes an audit event, because "the platform killed my sandbox" must be
answerable from the audit log.
2. **Report the policy in force.** The gateway is the one place where untrusted
code runs, so the worker logs the effective network policy and the absolute
caps at startup. An operator should be able to see the safety envelope
without reading the source.
The worker never executes a command. It only stops sessions — the command path
is ``SandboxService.run_command``, reachable from the API under a tenant check.
"""
from __future__ import annotations
from datetime import timedelta
from app.application.container import build_sandbox_service
from app.core.config import settings
from app.domain.audit.models import AuditAction, AuditEvent
from app.domain.base import utcnow
from app.infrastructure.repositories.audit import AuditRepository
from app.infrastructure.repositories.sandbox import WorkspaceRepository
from app.workers.base import WorkerOutcome, worker_session
#: Sessions are considered abandoned after this long with no activity.
DEFAULT_IDLE_MINUTES = 120
class SandboxGatewayWorker:
"""Stops idle sandbox sessions and records why."""
name = "sandbox-gateway"
async def reap(
self, *, idle_minutes: int = DEFAULT_IDLE_MINUTES, limit: int = 200
) -> WorkerOutcome:
outcome = WorkerOutcome(name=self.name)
idle_before = utcnow() - timedelta(minutes=max(1, idle_minutes))
async with worker_session() as session:
sandbox = build_sandbox_service(session)
workspaces = WorkspaceRepository(session)
audit = AuditRepository(session)
stopped = await sandbox.reap_idle_sessions(
idle_before=idle_before, limit=limit
)
for sandbox_session in stopped:
# Resolve the tenant for the audit trail. A session whose
# workspace has vanished is still stopped, just recorded
# without an organization.
workspace = await workspaces.get(sandbox_session.workspace_id)
await audit.record(
event=AuditEvent(
action=AuditAction.SANDBOX_REAP,
organization_id=workspace.organization_id if workspace else None,
target_type="sandbox_session",
target_id=sandbox_session.id,
metadata_json={
"workspace_id": sandbox_session.workspace_id,
"idle_minutes": idle_minutes,
"reason": "idle_timeout",
},
)
)
outcome.processed += 1
outcome.note(f"stopped session {sandbox_session.id}")
if not stopped:
outcome.skipped += 1
return outcome
def policy_report(self) -> WorkerOutcome:
"""Describe the safety envelope this gateway is running under."""
outcome = WorkerOutcome(name=self.name)
outcome.note(f"sandbox provider = {settings.sandbox_provider}")
outcome.note(
f"command timeout = {settings.sandbox_command_timeout_seconds}s "
f"(absolute cap enforced in the service layer)"
)
outcome.note(f"max output = {settings.sandbox_max_output_bytes} bytes")
outcome.note("egress policy default = deny_all (set per resource limit)")
return outcome
__all__ = ["DEFAULT_IDLE_MINUTES", "SandboxGatewayWorker"]