نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
Reference in New Issue
Block a user