agentevo-cloud 0.1.0__tar.gz → 0.1.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/PKG-INFO +1 -1
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/pyproject.toml +1 -1
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/app.py +53 -1
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/db.py +77 -3
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/devices.py +18 -6
- agentevo_cloud-0.1.2/tests/test_body_limit.py +103 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_devices_ci.py +12 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_foundation.py +92 -10
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_managed.py +2 -2
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/.gitignore +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/LICENSE +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/README.md +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/openapi/openapi.v1.json +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/__init__.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/analytics.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/cli.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/config.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/events.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/jobs.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/managed.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/metering.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/oidc.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/openapi.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/promote.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/recall.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/records.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/revoke.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/screen.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/sync.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/tenants.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/tokens.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/webhooks.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/workers.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_acceptance_m12.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_acceptance_m8.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_analytics.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_auth.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_jobs.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_managed.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_objects.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_sync.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_cli.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_compile.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_events.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_jobs.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_maintenance.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_metering_records.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_oidc.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_openapi.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_promote.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_recall.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_revoke.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_screen.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_smoke.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_sync_mode.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_sync_push_pull.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_webhooks.py +0 -0
- {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_workers.py +0 -0
|
@@ -10,7 +10,7 @@ response; metering increments in the request transaction.
|
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
12
|
import sqlite3
|
|
13
|
-
from collections.abc import Awaitable, Callable, Iterator
|
|
13
|
+
from collections.abc import Awaitable, Callable, Iterator, MutableMapping
|
|
14
14
|
from contextlib import suppress
|
|
15
15
|
from dataclasses import replace
|
|
16
16
|
from datetime import UTC, datetime, timedelta
|
|
@@ -148,6 +148,7 @@ def create_app(db: Database, settings: Settings) -> FastAPI:
|
|
|
148
148
|
app.middleware("http")(_metering_middleware)
|
|
149
149
|
app.middleware("http")(_commit_middleware)
|
|
150
150
|
app.middleware("http")(_maintenance_middleware)
|
|
151
|
+
app.middleware("http")(_body_limit_middleware)
|
|
151
152
|
app.get("/healthz")(_healthz)
|
|
152
153
|
app.post("/v1/auth/tokens", status_code=201)(_issue_token)
|
|
153
154
|
app.get("/v1/auth/tokens")(_list_tokens)
|
|
@@ -243,6 +244,15 @@ async def _http_handler(request: Request, exc: StarletteHTTPException) -> JSONRe
|
|
|
243
244
|
#: Methods that keep serving while the control plane is in maintenance.
|
|
244
245
|
_MAINTENANCE_SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS"})
|
|
245
246
|
|
|
247
|
+
#: Largest accepted request body (10 MiB). Sync/event batches are small by
|
|
248
|
+
#: contract (<=100 items); anything larger is a client bug or a memory-DoS
|
|
249
|
+
#: probe, rejected with 413 before parsing, metering, or the database run.
|
|
250
|
+
MAX_REQUEST_BODY_BYTES = 10 * 1024 * 1024
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
class _BodyTooLargeError(Exception):
|
|
254
|
+
"""Internal signal: a streaming body exceeded the cap mid-read."""
|
|
255
|
+
|
|
246
256
|
|
|
247
257
|
async def _maintenance_middleware(
|
|
248
258
|
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
@@ -267,6 +277,48 @@ async def _maintenance_middleware(
|
|
|
267
277
|
return await call_next(request)
|
|
268
278
|
|
|
269
279
|
|
|
280
|
+
async def _body_limit_middleware(
|
|
281
|
+
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
282
|
+
) -> Response:
|
|
283
|
+
"""Bound request-body memory before anything else runs.
|
|
284
|
+
|
|
285
|
+
Registered last so it runs outermost: declared over-cap bodies 413
|
|
286
|
+
without touching metering or the database, and undeclared (chunked)
|
|
287
|
+
bodies are counted while streaming with the same 413 past the cap.
|
|
288
|
+
"""
|
|
289
|
+
declared = request.headers.get("content-length")
|
|
290
|
+
if declared is not None:
|
|
291
|
+
try:
|
|
292
|
+
if int(declared) > MAX_REQUEST_BODY_BYTES:
|
|
293
|
+
return _too_large_response()
|
|
294
|
+
except ValueError:
|
|
295
|
+
pass
|
|
296
|
+
inner_receive = request.receive
|
|
297
|
+
seen = 0
|
|
298
|
+
|
|
299
|
+
async def _counting_receive() -> MutableMapping[str, Any]:
|
|
300
|
+
nonlocal seen
|
|
301
|
+
message = await inner_receive()
|
|
302
|
+
seen += len(message.get("body", b""))
|
|
303
|
+
if seen > MAX_REQUEST_BODY_BYTES:
|
|
304
|
+
raise _BodyTooLargeError
|
|
305
|
+
return message
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
return await call_next(Request(request.scope, _counting_receive))
|
|
309
|
+
except _BodyTooLargeError:
|
|
310
|
+
return _too_large_response()
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _too_large_response() -> JSONResponse:
|
|
314
|
+
exc = AgentevoError(
|
|
315
|
+
ErrorCode.PAYLOAD_TOO_LARGE,
|
|
316
|
+
f"request body exceeds {MAX_REQUEST_BODY_BYTES} bytes",
|
|
317
|
+
{"limit_bytes": MAX_REQUEST_BODY_BYTES},
|
|
318
|
+
)
|
|
319
|
+
return _error_response(exc.code, exc.message, exc.details, exc)
|
|
320
|
+
|
|
321
|
+
|
|
270
322
|
async def _metering_middleware(
|
|
271
323
|
request: Request, call_next: Callable[[Request], Awaitable[Response]]
|
|
272
324
|
) -> Response:
|
|
@@ -10,11 +10,23 @@ from __future__ import annotations
|
|
|
10
10
|
|
|
11
11
|
import os
|
|
12
12
|
import sqlite3
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
13
15
|
from collections.abc import Callable, Iterator
|
|
14
16
|
from contextlib import contextmanager, suppress
|
|
15
17
|
from pathlib import Path
|
|
16
18
|
|
|
17
|
-
SCHEMA_VERSION =
|
|
19
|
+
SCHEMA_VERSION = 9
|
|
20
|
+
|
|
21
|
+
#: Migration mutual exclusion. Five compose services share one SQLite file
|
|
22
|
+
#: and migrate lazily on first use; without this, concurrent first-boots race
|
|
23
|
+
#: the unguarded ALTERs (observed: duplicate column on production boot).
|
|
24
|
+
#: In-process via a thread lock, cross-process via an O_EXCL sidecar file
|
|
25
|
+
#: (portable: no fcntl, Windows-safe). Stale locks (crashed holder) break
|
|
26
|
+
#: after a minute; migrates take milliseconds, so waiting is the common case.
|
|
27
|
+
_MIGRATE_LOCK_TIMEOUT_SECONDS = 90.0
|
|
28
|
+
_MIGRATE_LOCK_STALE_SECONDS = 60.0
|
|
29
|
+
_MIGRATE_LOCK_POLL_SECONDS = 0.05
|
|
18
30
|
|
|
19
31
|
_SCHEMA_V1 = """
|
|
20
32
|
CREATE TABLE IF NOT EXISTS meta (
|
|
@@ -346,6 +358,10 @@ def _ensure_job_durability_columns(raw: sqlite3.Connection) -> None:
|
|
|
346
358
|
|
|
347
359
|
_MIGRATION_FOLLOWUPS[8] = _ensure_job_durability_columns
|
|
348
360
|
|
|
361
|
+
_SCHEMA_V9 = """
|
|
362
|
+
CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_links_repo ON repo_links (repo);
|
|
363
|
+
"""
|
|
364
|
+
|
|
349
365
|
MIGRATIONS: tuple[tuple[int, str], ...] = (
|
|
350
366
|
(1, _SCHEMA_V1),
|
|
351
367
|
(2, _SCHEMA_V2),
|
|
@@ -355,6 +371,7 @@ MIGRATIONS: tuple[tuple[int, str], ...] = (
|
|
|
355
371
|
(6, _SCHEMA_V6),
|
|
356
372
|
(7, _SCHEMA_V7),
|
|
357
373
|
(8, _SCHEMA_V8),
|
|
374
|
+
(9, _SCHEMA_V9),
|
|
358
375
|
)
|
|
359
376
|
|
|
360
377
|
|
|
@@ -365,6 +382,7 @@ class Database:
|
|
|
365
382
|
"""Bind a database file path (created + migrated on first use)."""
|
|
366
383
|
self._path = str(path)
|
|
367
384
|
self._migrated = False
|
|
385
|
+
self._lock = threading.Lock()
|
|
368
386
|
|
|
369
387
|
@property
|
|
370
388
|
def path(self) -> str:
|
|
@@ -372,8 +390,20 @@ class Database:
|
|
|
372
390
|
return self._path
|
|
373
391
|
|
|
374
392
|
def migrate(self) -> int:
|
|
375
|
-
"""Apply pending migrations; returns the schema version.
|
|
376
|
-
|
|
393
|
+
"""Apply pending migrations; returns the schema version.
|
|
394
|
+
|
|
395
|
+
Serialized in-process and cross-process: concurrent first-boots of
|
|
396
|
+
several services sharing one file must not interleave schema writes.
|
|
397
|
+
"""
|
|
398
|
+
with self._lock:
|
|
399
|
+
_secure_file(self._path)
|
|
400
|
+
if self._path == ":memory:":
|
|
401
|
+
return self._migrate_unsafe()
|
|
402
|
+
with _migrate_file_lock(self._path):
|
|
403
|
+
return self._migrate_unsafe()
|
|
404
|
+
|
|
405
|
+
def _migrate_unsafe(self) -> int:
|
|
406
|
+
"""Run pending migrations; caller holds the migration locks."""
|
|
377
407
|
raw = sqlite3.connect(self._path, timeout=30.0)
|
|
378
408
|
try:
|
|
379
409
|
raw.execute("PRAGMA journal_mode=WAL")
|
|
@@ -427,6 +457,50 @@ def _secure_file(path: str) -> None:
|
|
|
427
457
|
os.chmod(path, 0o600)
|
|
428
458
|
|
|
429
459
|
|
|
460
|
+
@contextmanager
|
|
461
|
+
def _migrate_file_lock(path: str, timeout: float = _MIGRATE_LOCK_TIMEOUT_SECONDS) -> Iterator[None]:
|
|
462
|
+
"""Hold an exclusive cross-process migration lock for ``path``.
|
|
463
|
+
|
|
464
|
+
Atomic ``O_EXCL`` create, so exactly one first-boot wins; losers poll
|
|
465
|
+
until the winner finishes (milliseconds) or break the lock when its
|
|
466
|
+
mtime proves the holder crashed. Raises ``TimeoutError`` past ``timeout``
|
|
467
|
+
instead of wedging a boot forever.
|
|
468
|
+
"""
|
|
469
|
+
lock = f"{path}.migrate.lock"
|
|
470
|
+
deadline = time.monotonic() + timeout
|
|
471
|
+
while True:
|
|
472
|
+
try:
|
|
473
|
+
fd = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
474
|
+
except FileExistsError:
|
|
475
|
+
if _migrate_lock_is_stale(lock):
|
|
476
|
+
with suppress(OSError):
|
|
477
|
+
os.unlink(lock)
|
|
478
|
+
continue
|
|
479
|
+
if time.monotonic() >= deadline:
|
|
480
|
+
raise TimeoutError(f"timed out waiting for migration lock: {lock}") from None
|
|
481
|
+
time.sleep(_MIGRATE_LOCK_POLL_SECONDS)
|
|
482
|
+
continue
|
|
483
|
+
try:
|
|
484
|
+
os.write(fd, str(os.getpid()).encode("ascii"))
|
|
485
|
+
finally:
|
|
486
|
+
os.close(fd)
|
|
487
|
+
break
|
|
488
|
+
try:
|
|
489
|
+
yield
|
|
490
|
+
finally:
|
|
491
|
+
with suppress(FileNotFoundError):
|
|
492
|
+
os.unlink(lock)
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _migrate_lock_is_stale(lock: str) -> bool:
|
|
496
|
+
"""True when ``lock`` outlived any plausible migrate (crashed holder)."""
|
|
497
|
+
try:
|
|
498
|
+
age = time.time() - os.path.getmtime(lock)
|
|
499
|
+
except OSError:
|
|
500
|
+
return True # Vanished mid-check: safe to break and retry.
|
|
501
|
+
return age > _MIGRATE_LOCK_STALE_SECONDS
|
|
502
|
+
|
|
503
|
+
|
|
430
504
|
def _current_version(conn: sqlite3.Connection) -> int:
|
|
431
505
|
table = conn.execute(
|
|
432
506
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'meta'"
|
|
@@ -447,7 +447,12 @@ def exchange_ci_token(
|
|
|
447
447
|
|
|
448
448
|
|
|
449
449
|
def link_repo(conn: sqlite3.Connection, tenant_id: str, repo: str, workspace_slug: str) -> RepoLink:
|
|
450
|
-
"""Bind a CI repository to one tenant workspace (owner/name form).
|
|
450
|
+
"""Bind a CI repository to one tenant workspace (owner/name form).
|
|
451
|
+
|
|
452
|
+
Repos are globally unique: owner/name identifies one upstream repository,
|
|
453
|
+
so a second tenant linking the same name conflicts instead of shadowing
|
|
454
|
+
the CI exchange resolution.
|
|
455
|
+
"""
|
|
451
456
|
if get_tenant(conn, tenant_id) is None:
|
|
452
457
|
raise AgentevoError(
|
|
453
458
|
ErrorCode.NOT_FOUND, f"unknown tenant: {tenant_id}", {"tenant_id": tenant_id}
|
|
@@ -464,11 +469,18 @@ def link_repo(conn: sqlite3.Connection, tenant_id: str, repo: str, workspace_slu
|
|
|
464
469
|
f"unknown workspace: {workspace_slug}",
|
|
465
470
|
{"workspace": workspace_slug},
|
|
466
471
|
)
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
+
try:
|
|
473
|
+
conn.execute(
|
|
474
|
+
"INSERT INTO repo_links (tenant_id, repo, workspace_slug) VALUES (?, ?, ?)"
|
|
475
|
+
" ON CONFLICT (tenant_id, repo) DO UPDATE SET workspace_slug = excluded.workspace_slug",
|
|
476
|
+
(tenant_id, repo, workspace_slug),
|
|
477
|
+
)
|
|
478
|
+
except sqlite3.IntegrityError as exc:
|
|
479
|
+
raise AgentevoError(
|
|
480
|
+
ErrorCode.CONFLICT,
|
|
481
|
+
"repository is already linked",
|
|
482
|
+
{"repository": repo},
|
|
483
|
+
) from exc
|
|
472
484
|
return RepoLink(tenant_id=tenant_id, repo=repo, workspace_slug=workspace_slug)
|
|
473
485
|
|
|
474
486
|
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Request body cap: oversized bodies 413 before parsing/metering (F1)."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
from fastapi.testclient import TestClient
|
|
10
|
+
from starlette.requests import Request
|
|
11
|
+
from starlette.responses import JSONResponse, Response
|
|
12
|
+
|
|
13
|
+
from agentevo_cloud.app import MAX_REQUEST_BODY_BYTES, _body_limit_middleware, create_app
|
|
14
|
+
from agentevo_cloud.cli import provision
|
|
15
|
+
from agentevo_cloud.config import Settings
|
|
16
|
+
from agentevo_cloud.db import Database
|
|
17
|
+
|
|
18
|
+
STAMP = datetime(2026, 9, 21, 12, 0, 0, tzinfo=UTC)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def client(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Any:
|
|
23
|
+
monkeypatch.setenv("AGENTEVO_DB_PATH", str(tmp_path / "bodylimit.db"))
|
|
24
|
+
monkeypatch.setenv("AGENTEVO_RATE_LIMIT", "1000000")
|
|
25
|
+
settings = Settings()
|
|
26
|
+
db = Database(settings.db_path)
|
|
27
|
+
seeded = provision(
|
|
28
|
+
db, tenant_id="t-1", name="Acme", user_id="u-1", workspace_slug="demo", now=STAMP
|
|
29
|
+
)
|
|
30
|
+
handle = TestClient(create_app(db, settings))
|
|
31
|
+
handle.provisioned = seeded # type: ignore[attr-defined]
|
|
32
|
+
handle.db = db # type: ignore[attr-defined]
|
|
33
|
+
return handle
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def auth(secret: str) -> dict[str, str]:
|
|
37
|
+
return {"Authorization": f"Bearer {secret}"}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_declared_over_cap_413_unmetered(client: Any) -> None:
|
|
41
|
+
secret = client.provisioned["secret"]
|
|
42
|
+
used_before = client.get("/v1/usage", headers=auth(secret)).json()["used"]
|
|
43
|
+
big = "x" * (MAX_REQUEST_BODY_BYTES + 1)
|
|
44
|
+
response = client.post("/v1/recall", headers=auth(secret), json={"text": big})
|
|
45
|
+
assert response.status_code == 413
|
|
46
|
+
body = response.json()
|
|
47
|
+
assert body["code"] == "PAYLOAD_TOO_LARGE"
|
|
48
|
+
assert body["details"] == {"limit_bytes": MAX_REQUEST_BODY_BYTES}
|
|
49
|
+
assert "x" * 100 not in response.text
|
|
50
|
+
used_after = client.get("/v1/usage", headers=auth(secret)).json()["used"]
|
|
51
|
+
assert used_after == used_before
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_exactly_at_cap_passes(client: Any) -> None:
|
|
55
|
+
secret = client.provisioned["secret"]
|
|
56
|
+
pad = MAX_REQUEST_BODY_BYTES - len('{"text": ""}')
|
|
57
|
+
response = client.post("/v1/recall", headers=auth(secret), json={"text": "x" * pad})
|
|
58
|
+
assert response.status_code == 200
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_middleware(scope: dict[str, Any], messages: list[dict[str, Any]]) -> Response:
|
|
62
|
+
queue = list(messages)
|
|
63
|
+
|
|
64
|
+
async def _receive() -> dict[str, Any]:
|
|
65
|
+
return queue.pop(0)
|
|
66
|
+
|
|
67
|
+
async def _call_next(request: Request) -> Response:
|
|
68
|
+
body = b""
|
|
69
|
+
while True:
|
|
70
|
+
message = await request.receive()
|
|
71
|
+
body += message.get("body", b"")
|
|
72
|
+
if not message.get("more_body", False):
|
|
73
|
+
break
|
|
74
|
+
return JSONResponse({"received": len(body)})
|
|
75
|
+
|
|
76
|
+
return asyncio.run(_body_limit_middleware(Request(scope, _receive), _call_next))
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _scope(headers: list[tuple[bytes, bytes]]) -> dict[str, Any]:
|
|
80
|
+
return {"type": "http", "method": "POST", "path": "/", "headers": headers}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_invalid_content_length_falls_through_to_stream_count() -> None:
|
|
84
|
+
scope = _scope([(b"content-length", b"bogus")])
|
|
85
|
+
response = _run_middleware(scope, [{"type": "http.request", "body": b"{}", "more_body": False}])
|
|
86
|
+
assert response.status_code == 200
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_chunked_over_cap_413() -> None:
|
|
90
|
+
chunk = b"y" * (MAX_REQUEST_BODY_BYTES // 2 + 1)
|
|
91
|
+
messages = [
|
|
92
|
+
{"type": "http.request", "body": chunk, "more_body": True},
|
|
93
|
+
{"type": "http.request", "body": chunk, "more_body": False},
|
|
94
|
+
]
|
|
95
|
+
response = _run_middleware(_scope([]), messages)
|
|
96
|
+
assert response.status_code == 413
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_chunked_under_cap_passes() -> None:
|
|
100
|
+
response = _run_middleware(
|
|
101
|
+
_scope([]), [{"type": "http.request", "body": b"{}", "more_body": False}]
|
|
102
|
+
)
|
|
103
|
+
assert response.status_code == 200
|
|
@@ -729,6 +729,18 @@ def test_repo_link_lifecycle(conn: sqlite3.Connection) -> None:
|
|
|
729
729
|
assert exc_info.value.code is ErrorCode.NOT_FOUND
|
|
730
730
|
|
|
731
731
|
|
|
732
|
+
def test_repo_link_rejects_second_tenant(conn: sqlite3.Connection) -> None:
|
|
733
|
+
create_tenant(conn, "t-2", "Rival", now=STAMP)
|
|
734
|
+
create_workspace(conn, "t-2", "demo", "Demo", now=STAMP)
|
|
735
|
+
link_repo(conn, "t-1", "acme/app", "demo")
|
|
736
|
+
with pytest.raises(AgentevoError) as exc_info:
|
|
737
|
+
link_repo(conn, "t-2", "acme/app", "demo")
|
|
738
|
+
assert exc_info.value.code is ErrorCode.CONFLICT
|
|
739
|
+
assert exc_info.value.details == {"repository": "acme/app"}
|
|
740
|
+
found = find_repo(conn, "acme/app")
|
|
741
|
+
assert found is not None and found.tenant_id == "t-1"
|
|
742
|
+
|
|
743
|
+
|
|
732
744
|
def test_repo_link_validates_input(conn: sqlite3.Connection) -> None:
|
|
733
745
|
with pytest.raises(AgentevoError) as exc_info:
|
|
734
746
|
link_repo(conn, "t-9", "acme/app", "demo")
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
4
|
import sqlite3
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
8
|
from datetime import UTC, datetime, timedelta
|
|
6
9
|
from pathlib import Path
|
|
7
10
|
|
|
8
11
|
import pytest
|
|
9
12
|
|
|
10
|
-
from agentevo_cloud.db import MIGRATIONS, SCHEMA_VERSION, Database
|
|
13
|
+
from agentevo_cloud.db import MIGRATIONS, SCHEMA_VERSION, Database, _migrate_file_lock
|
|
11
14
|
from agentevo_cloud.tenants import (
|
|
12
15
|
FREE_MONTHLY_QUOTA,
|
|
13
16
|
add_membership,
|
|
@@ -54,6 +57,85 @@ def test_migrate_is_idempotent(db: Database) -> None:
|
|
|
54
57
|
assert db.path.endswith("cloud.db")
|
|
55
58
|
|
|
56
59
|
|
|
60
|
+
def test_concurrent_migrate_single_file_is_clean(tmp_path: Path) -> None:
|
|
61
|
+
"""Eight racers, one file: every migrate lands on SCHEMA_VERSION.
|
|
62
|
+
|
|
63
|
+
Regression: five compose services migrating one fresh SQLite file raced
|
|
64
|
+
the unguarded V2 ALTERs (duplicate column) on first boot.
|
|
65
|
+
"""
|
|
66
|
+
path = tmp_path / "race.db"
|
|
67
|
+
|
|
68
|
+
def worker(_: int) -> int:
|
|
69
|
+
return Database(path).migrate()
|
|
70
|
+
|
|
71
|
+
with ThreadPoolExecutor(max_workers=8) as pool:
|
|
72
|
+
assert list(pool.map(worker, range(8))) == [SCHEMA_VERSION] * 8
|
|
73
|
+
assert Database(path).migrate() == SCHEMA_VERSION
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_migrate_breaks_stale_lock(tmp_path: Path) -> None:
|
|
77
|
+
"""A lock file from a crashed migrator does not wedge future boots."""
|
|
78
|
+
path = tmp_path / "stale.db"
|
|
79
|
+
lock = tmp_path / "stale.db.migrate.lock"
|
|
80
|
+
lock.write_text("dead-pid", encoding="utf-8")
|
|
81
|
+
ancient = 1_000_000_000.0
|
|
82
|
+
os.utime(lock, (ancient, ancient))
|
|
83
|
+
assert Database(path).migrate() == SCHEMA_VERSION
|
|
84
|
+
assert not lock.exists()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_migrate_file_lock_waits_then_acquires(tmp_path: Path) -> None:
|
|
88
|
+
"""A held (fresh) lock blocks until release, then migration proceeds."""
|
|
89
|
+
path = str(tmp_path / "wait.db")
|
|
90
|
+
lock = tmp_path / "wait.db.migrate.lock"
|
|
91
|
+
lock.write_text("holder", encoding="utf-8")
|
|
92
|
+
acquired: list[bool] = []
|
|
93
|
+
|
|
94
|
+
def worker() -> None:
|
|
95
|
+
with _migrate_file_lock(path, timeout=10.0):
|
|
96
|
+
acquired.append(True)
|
|
97
|
+
|
|
98
|
+
thread = threading.Thread(target=worker)
|
|
99
|
+
thread.start()
|
|
100
|
+
time.sleep(0.2)
|
|
101
|
+
assert acquired == []
|
|
102
|
+
lock.unlink()
|
|
103
|
+
thread.join(timeout=10.0)
|
|
104
|
+
assert acquired == [True]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_migrate_file_lock_times_out(tmp_path: Path) -> None:
|
|
108
|
+
"""A held lock past the deadline raises instead of hanging a boot."""
|
|
109
|
+
path = str(tmp_path / "busy.db")
|
|
110
|
+
(tmp_path / "busy.db.migrate.lock").write_text("holder", encoding="utf-8")
|
|
111
|
+
with (
|
|
112
|
+
pytest.raises(TimeoutError, match="migration lock"),
|
|
113
|
+
_migrate_file_lock(path, timeout=0.0),
|
|
114
|
+
):
|
|
115
|
+
pass # acquire must raise first
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def test_migrate_lock_tolerates_vanishing_lock(
|
|
119
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
120
|
+
) -> None:
|
|
121
|
+
"""A lock deleted between contention and mtime check retries cleanly."""
|
|
122
|
+
path = str(tmp_path / "gone.db")
|
|
123
|
+
(tmp_path / "gone.db.migrate.lock").write_text("holder", encoding="utf-8")
|
|
124
|
+
real_getmtime = os.path.getmtime
|
|
125
|
+
calls = []
|
|
126
|
+
|
|
127
|
+
def flaky_getmtime(target: str) -> float:
|
|
128
|
+
calls.append(target)
|
|
129
|
+
if len(calls) == 1:
|
|
130
|
+
raise OSError("vanished")
|
|
131
|
+
return real_getmtime(target)
|
|
132
|
+
|
|
133
|
+
monkeypatch.setattr(os.path, "getmtime", flaky_getmtime)
|
|
134
|
+
with _migrate_file_lock(path, timeout=5.0):
|
|
135
|
+
pass
|
|
136
|
+
assert len(calls) == 1
|
|
137
|
+
|
|
138
|
+
|
|
57
139
|
def test_database_file_is_owner_only(tmp_path: Path) -> None:
|
|
58
140
|
path = tmp_path / "cloud.db"
|
|
59
141
|
assert Database(path).migrate() == SCHEMA_VERSION
|
|
@@ -68,7 +150,7 @@ def test_database_memory_skips_file_lockdown() -> None:
|
|
|
68
150
|
|
|
69
151
|
|
|
70
152
|
def test_v6_jobs_table_and_verification_column(db: Database) -> None:
|
|
71
|
-
assert db.migrate() ==
|
|
153
|
+
assert db.migrate() == 9
|
|
72
154
|
with db.connect() as conn:
|
|
73
155
|
conn.execute(
|
|
74
156
|
"INSERT INTO jobs (tenant_id, job_id, job_type, object_id, version,"
|
|
@@ -88,16 +170,16 @@ def test_v6_jobs_table_and_verification_column(db: Database) -> None:
|
|
|
88
170
|
|
|
89
171
|
def test_v6_replay_with_objects_present_is_clean(tmp_path: Path) -> None:
|
|
90
172
|
path = tmp_path / "replay.db"
|
|
91
|
-
assert Database(path).migrate() ==
|
|
173
|
+
assert Database(path).migrate() == 9
|
|
92
174
|
raw = sqlite3.connect(str(path))
|
|
93
175
|
raw.execute("UPDATE meta SET value = '5' WHERE key = 'schema_version'")
|
|
94
176
|
raw.commit()
|
|
95
177
|
raw.close()
|
|
96
|
-
assert Database(path).migrate() ==
|
|
178
|
+
assert Database(path).migrate() == 9 # Guarded ALTERs survive replays.
|
|
97
179
|
|
|
98
180
|
|
|
99
181
|
def test_v7_sync_mode_columns_default_local_only(db: Database) -> None:
|
|
100
|
-
assert db.migrate() ==
|
|
182
|
+
assert db.migrate() == 9
|
|
101
183
|
with db.connect() as conn:
|
|
102
184
|
columns = {
|
|
103
185
|
item["name"] for item in conn.execute("PRAGMA table_info(workspaces)").fetchall()
|
|
@@ -121,7 +203,7 @@ def test_v7_sync_mode_columns_default_local_only(db: Database) -> None:
|
|
|
121
203
|
|
|
122
204
|
def test_v7_replay_with_mode_set_is_clean(tmp_path: Path) -> None:
|
|
123
205
|
path = tmp_path / "replay7.db"
|
|
124
|
-
assert Database(path).migrate() ==
|
|
206
|
+
assert Database(path).migrate() == 9
|
|
125
207
|
raw = sqlite3.connect(str(path))
|
|
126
208
|
raw.execute(
|
|
127
209
|
"INSERT INTO tenants (id, name, plan, created_at) VALUES ('t-1', 'Acme', 'free', ?)",
|
|
@@ -136,7 +218,7 @@ def test_v7_replay_with_mode_set_is_clean(tmp_path: Path) -> None:
|
|
|
136
218
|
raw.execute("UPDATE meta SET value = '6' WHERE key = 'schema_version'")
|
|
137
219
|
raw.commit()
|
|
138
220
|
raw.close()
|
|
139
|
-
assert Database(path).migrate() ==
|
|
221
|
+
assert Database(path).migrate() == 9 # Followup skips existing columns.
|
|
140
222
|
check = sqlite3.connect(str(path))
|
|
141
223
|
try:
|
|
142
224
|
row = check.execute("SELECT sync_mode FROM workspaces").fetchone()
|
|
@@ -146,7 +228,7 @@ def test_v7_replay_with_mode_set_is_clean(tmp_path: Path) -> None:
|
|
|
146
228
|
|
|
147
229
|
|
|
148
230
|
def test_v8_job_durability_columns_default_zero(db: Database) -> None:
|
|
149
|
-
assert db.migrate() ==
|
|
231
|
+
assert db.migrate() == 9
|
|
150
232
|
with db.connect() as conn:
|
|
151
233
|
columns = {item["name"] for item in conn.execute("PRAGMA table_info(jobs)").fetchall()}
|
|
152
234
|
assert {"attempts", "lease_expires_at", "next_run_at"} <= columns
|
|
@@ -165,7 +247,7 @@ def test_v8_job_durability_columns_default_zero(db: Database) -> None:
|
|
|
165
247
|
|
|
166
248
|
def test_v8_replay_with_attempts_set_is_clean(tmp_path: Path) -> None:
|
|
167
249
|
path = tmp_path / "replay8.db"
|
|
168
|
-
assert Database(path).migrate() ==
|
|
250
|
+
assert Database(path).migrate() == 9
|
|
169
251
|
raw = sqlite3.connect(str(path))
|
|
170
252
|
raw.execute(
|
|
171
253
|
"INSERT INTO jobs (tenant_id, job_id, job_type, object_id, version,"
|
|
@@ -176,7 +258,7 @@ def test_v8_replay_with_attempts_set_is_clean(tmp_path: Path) -> None:
|
|
|
176
258
|
raw.execute("UPDATE meta SET value = '7' WHERE key = 'schema_version'")
|
|
177
259
|
raw.commit()
|
|
178
260
|
raw.close()
|
|
179
|
-
assert Database(path).migrate() ==
|
|
261
|
+
assert Database(path).migrate() == 9 # Followup skips existing columns.
|
|
180
262
|
check = sqlite3.connect(str(path))
|
|
181
263
|
try:
|
|
182
264
|
row = check.execute("SELECT attempts FROM jobs").fetchone()
|
|
@@ -223,7 +223,7 @@ def test_v5_backfill_indexes_preexisting_deltas(tmp_path: Path) -> None:
|
|
|
223
223
|
raw.execute("UPDATE meta SET value = '4' WHERE key = 'schema_version'")
|
|
224
224
|
raw.commit()
|
|
225
225
|
raw.close()
|
|
226
|
-
assert Database(path).migrate() ==
|
|
226
|
+
assert Database(path).migrate() == 9
|
|
227
227
|
with Database(path).connect() as conn:
|
|
228
228
|
kept = get_managed(conn, "t-1", "skill:kept", 1)
|
|
229
229
|
assert kept is not None and kept.state == "Candidate"
|
|
@@ -234,4 +234,4 @@ def test_v5_backfill_indexes_preexisting_deltas(tmp_path: Path) -> None:
|
|
|
234
234
|
).fetchone()
|
|
235
235
|
assert gone is not None and gone["state"] == "Revoked"
|
|
236
236
|
assert get_managed(conn, "t-1", "skill:sealed", 1) is None
|
|
237
|
-
assert Database(path).migrate() ==
|
|
237
|
+
assert Database(path).migrate() == 9 # Backfill reruns are no-ops.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|