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.
Files changed (59) hide show
  1. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/PKG-INFO +1 -1
  2. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/pyproject.toml +1 -1
  3. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/app.py +53 -1
  4. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/db.py +77 -3
  5. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/devices.py +18 -6
  6. agentevo_cloud-0.1.2/tests/test_body_limit.py +103 -0
  7. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_devices_ci.py +12 -0
  8. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_foundation.py +92 -10
  9. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_managed.py +2 -2
  10. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/.gitignore +0 -0
  11. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/LICENSE +0 -0
  12. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/README.md +0 -0
  13. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/openapi/openapi.v1.json +0 -0
  14. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/__init__.py +0 -0
  15. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/analytics.py +0 -0
  16. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/cli.py +0 -0
  17. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/config.py +0 -0
  18. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/events.py +0 -0
  19. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/jobs.py +0 -0
  20. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/managed.py +0 -0
  21. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/metering.py +0 -0
  22. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/oidc.py +0 -0
  23. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/openapi.py +0 -0
  24. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/promote.py +0 -0
  25. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/recall.py +0 -0
  26. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/records.py +0 -0
  27. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/revoke.py +0 -0
  28. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/screen.py +0 -0
  29. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/sync.py +0 -0
  30. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/tenants.py +0 -0
  31. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/tokens.py +0 -0
  32. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/webhooks.py +0 -0
  33. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/src/agentevo_cloud/workers.py +0 -0
  34. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_acceptance_m12.py +0 -0
  35. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_acceptance_m8.py +0 -0
  36. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_analytics.py +0 -0
  37. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app.py +0 -0
  38. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_auth.py +0 -0
  39. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_jobs.py +0 -0
  40. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_managed.py +0 -0
  41. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_objects.py +0 -0
  42. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_app_sync.py +0 -0
  43. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_cli.py +0 -0
  44. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_compile.py +0 -0
  45. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_events.py +0 -0
  46. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_jobs.py +0 -0
  47. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_maintenance.py +0 -0
  48. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_metering_records.py +0 -0
  49. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_oidc.py +0 -0
  50. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_openapi.py +0 -0
  51. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_promote.py +0 -0
  52. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_recall.py +0 -0
  53. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_revoke.py +0 -0
  54. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_screen.py +0 -0
  55. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_smoke.py +0 -0
  56. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_sync_mode.py +0 -0
  57. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_sync_push_pull.py +0 -0
  58. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_webhooks.py +0 -0
  59. {agentevo_cloud-0.1.0 → agentevo_cloud-0.1.2}/tests/test_workers.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentevo-cloud
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Agentevo SaaS (TZ-3 control plane, TZ-4 managed intelligence): auth, metering, C2 APIs, workers.
5
5
  Project-URL: Homepage, https://agentevo.sh
6
6
  Author: Agentevo
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentevo-cloud"
3
- version = "0.1.0"
3
+ version = "0.1.2"
4
4
  description = "Agentevo SaaS (TZ-3 control plane, TZ-4 managed intelligence): auth, metering, C2 APIs, workers."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -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 = 8
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
- _secure_file(self._path)
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
- conn.execute(
468
- "INSERT INTO repo_links (tenant_id, repo, workspace_slug) VALUES (?, ?, ?)"
469
- " ON CONFLICT (tenant_id, repo) DO UPDATE SET workspace_slug = excluded.workspace_slug",
470
- (tenant_id, repo, workspace_slug),
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() == 8
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() == 8
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() == 8 # Guarded ALTERs survive replays.
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() == 8
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() == 8
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() == 8 # Followup skips existing columns.
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() == 8
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() == 8
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() == 8 # Followup skips existing columns.
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() == 8
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() == 8 # Backfill reruns are no-ops.
237
+ assert Database(path).migrate() == 9 # Backfill reruns are no-ops.
File without changes
File without changes