agentevo-cloud 0.1.2__tar.gz → 0.1.4__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.2 → agentevo_cloud-0.1.4}/LICENSE +1 -1
  2. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/PKG-INFO +8 -2
  3. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/README.md +6 -0
  4. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/pyproject.toml +1 -1
  5. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/__init__.py +1 -0
  6. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/analytics.py +1 -0
  7. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/app.py +76 -13
  8. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/cli.py +1 -0
  9. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/config.py +1 -0
  10. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/db.py +45 -4
  11. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/devices.py +5 -2
  12. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/events.py +1 -0
  13. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/jobs.py +33 -14
  14. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/managed.py +1 -0
  15. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/metering.py +1 -0
  16. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/oidc.py +1 -0
  17. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/openapi.py +1 -0
  18. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/promote.py +3 -1
  19. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/recall.py +1 -0
  20. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/records.py +1 -0
  21. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/revoke.py +4 -1
  22. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/screen.py +1 -0
  23. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/sync.py +1 -0
  24. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/tenants.py +1 -0
  25. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/tokens.py +59 -4
  26. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/webhooks.py +1 -0
  27. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/workers.py +1 -0
  28. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_acceptance_m12.py +1 -0
  29. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_acceptance_m8.py +1 -0
  30. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_analytics.py +1 -0
  31. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app.py +1 -0
  32. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_auth.py +127 -4
  33. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_jobs.py +61 -0
  34. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_managed.py +1 -0
  35. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_objects.py +1 -0
  36. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_sync.py +1 -0
  37. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_body_limit.py +1 -0
  38. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_cli.py +1 -0
  39. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_compile.py +1 -0
  40. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_devices_ci.py +2 -1
  41. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_events.py +1 -0
  42. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_foundation.py +85 -10
  43. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_jobs.py +59 -0
  44. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_maintenance.py +1 -0
  45. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_managed.py +4 -3
  46. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_metering_records.py +1 -0
  47. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_oidc.py +1 -0
  48. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_openapi.py +1 -0
  49. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_promote.py +1 -0
  50. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_recall.py +1 -0
  51. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_revoke.py +1 -0
  52. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_screen.py +1 -0
  53. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_smoke.py +1 -0
  54. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_sync_mode.py +30 -0
  55. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_sync_push_pull.py +1 -0
  56. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_webhooks.py +1 -0
  57. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_workers.py +1 -0
  58. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/.gitignore +0 -0
  59. {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/openapi/openapi.v1.json +0 -0
@@ -3,7 +3,7 @@ Business Source License 1.1
3
3
  Parameters
4
4
 
5
5
  Licensor: Agentevo
6
- Licensed Work: agentevo-cloud 0.1.0
6
+ Licensed Work: agentevo-cloud 0.1.4
7
7
  Copyright Notice: Copyright (c) 2026 Agentevo
8
8
 
9
9
  Additional Use Grant:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentevo-cloud
3
- Version: 0.1.2
3
+ Version: 0.1.4
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
@@ -9,7 +9,7 @@ License: Business Source License 1.1
9
9
  Parameters
10
10
 
11
11
  Licensor: Agentevo
12
- Licensed Work: agentevo-cloud 0.1.0
12
+ Licensed Work: agentevo-cloud 0.1.4
13
13
  Copyright Notice: Copyright (c) 2026 Agentevo
14
14
 
15
15
  Additional Use Grant:
@@ -125,3 +125,9 @@ agentevo-cloud serve
125
125
  ```
126
126
 
127
127
  Part of the Agentevo monorepo; see the [main README](../../README.md).
128
+
129
+ ## License
130
+
131
+ Business Source License 1.1 — see [LICENSE](./LICENSE). Free for internal
132
+ production use in your own infrastructure; converts to Apache-2.0 on the
133
+ Change Date stated in the license.
@@ -12,3 +12,9 @@ agentevo-cloud serve
12
12
  ```
13
13
 
14
14
  Part of the Agentevo monorepo; see the [main README](../../README.md).
15
+
16
+ ## License
17
+
18
+ Business Source License 1.1 — see [LICENSE](./LICENSE). Free for internal
19
+ production use in your own infrastructure; converts to Apache-2.0 on the
20
+ Change Date stated in the license.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentevo-cloud"
3
- version = "0.1.2"
3
+ version = "0.1.4"
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"
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Agentevo SaaS control plane and managed intelligence APIs."""
2
3
 
3
4
  from agentevo_cloud.analytics import (
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Team analytics: tenant-scoped aggregates over the managed index.
2
3
 
3
4
  Spec grounding: PRD §15 ("record improved execution metrics"), PRD §16
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Cloud Control API: FastAPI factory, error model, and routes.
2
3
 
3
4
  Every route authenticates via Bearer tokens except /healthz and the auth
@@ -95,6 +96,7 @@ from agentevo_cloud.tokens import (
95
96
  hash_token,
96
97
  issue_pat,
97
98
  list_tokens,
99
+ require_workspace,
98
100
  revoke_token,
99
101
  rotate_refresh,
100
102
  verify_token,
@@ -538,9 +540,16 @@ def _client_ip(request: Request) -> str:
538
540
 
539
541
 
540
542
  def _auth_rate_limit(request: Request, conn: sqlite3.Connection, name: str) -> None:
541
- """IP-keyed burst guard for the unauthenticated auth flows."""
543
+ """IP-keyed burst guard for the unauthenticated auth flows.
544
+
545
+ Commits the increment immediately: burst counters must survive the
546
+ request transaction's rollback-on-error, or failed guesses would never
547
+ count. Callers must run this before any request-conn writes (all do:
548
+ it gates handler entry, and dependencies only read).
549
+ """
542
550
  settings: Settings = request.app.state.settings
543
551
  check_rate_limit(conn, f"auth:{name}:{_client_ip(request)}", settings.rate_limit_per_minute)
552
+ conn.commit()
544
553
 
545
554
 
546
555
  def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
@@ -555,8 +564,15 @@ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
555
564
 
556
565
  Anonymous bootstrap flows (device start, CI exchange) share the empty
557
566
  tenant namespace; their responses carry no tenant data before approval.
567
+ Their replays are additionally scoped by client IP, so one anonymous
568
+ client reusing a generic key cannot replay another client's live
569
+ device codes. Stored replays withhold single-view secrets (issuance
570
+ responses keep ids/scopes/expiry only); the live response is
571
+ unaffected.
558
572
  """
559
573
  route = f"{request.method} {request.url.path}"
574
+ if not tenant_id:
575
+ route = f"{_client_ip(request)} {route}"
560
576
  if key is not None:
561
577
  replay = idempotency_lookup(conn, tenant_id, route, key, body)
562
578
  if replay is not None:
@@ -573,7 +589,7 @@ def _idempotent( # noqa: PLR0913, PLR0917 - idempotency carries request context
573
589
  key,
574
590
  body=body,
575
591
  status_code=status,
576
- response=payload,
592
+ response=_scrub_replay_secrets(payload),
577
593
  ttl=timedelta(seconds=settings.idempotency_ttl_seconds),
578
594
  )
579
595
  if raced is not None:
@@ -619,6 +635,8 @@ def _issue_token(
619
635
  user_id=principal.user_id or "",
620
636
  workspaces=workspaces,
621
637
  ttl=ttl,
638
+ grantor_scopes=list(principal.scopes),
639
+ grantor_workspaces=list(principal.workspaces),
622
640
  )
623
641
  audit(
624
642
  conn,
@@ -639,6 +657,19 @@ def _issue_token(
639
657
  return _idempotent(request, conn, principal.tenant_id, idempotency_key, body, compute)
640
658
 
641
659
 
660
+ #: Top-level response keys withheld from the idempotency replay store.
661
+ #: Single-view issuance secrets must not persist in plaintext for the key
662
+ #: TTL: replays of issuance routes return metadata (ids, scopes, expiry)
663
+ #: without the secret itself. (Request bodies are hash-compared, never
664
+ #: stored; short-lived device/user codes stay stored: retry UX needs them.)
665
+ _SECRET_REPLAY_KEYS = frozenset({"secret", "access_token", "refresh_token", "token"})
666
+
667
+
668
+ def _scrub_replay_secrets(payload: dict[str, Any]) -> dict[str, Any]:
669
+ """Copy ``payload`` minus single-view credential fields."""
670
+ return {key: value for key, value in payload.items() if key not in _SECRET_REPLAY_KEYS}
671
+
672
+
642
673
  def _list_tokens(
643
674
  principal: Principal = Depends(CONTROL_READ),
644
675
  conn: sqlite3.Connection = Depends(get_conn),
@@ -1625,9 +1656,13 @@ def _body_target(body: dict[str, Any]) -> TrustState:
1625
1656
 
1626
1657
 
1627
1658
  def _managed_or_404(
1628
- conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
1659
+ conn: sqlite3.Connection,
1660
+ tenant_id: str,
1661
+ object_id: str,
1662
+ version: int,
1663
+ principal: Principal,
1629
1664
  ) -> ManagedObject:
1630
- """One indexed version, tenant-scoped (404 reveals nothing across tenants)."""
1665
+ """One indexed version, tenant- and workspace-scoped (404 reveals nothing)."""
1631
1666
  target = get_managed(conn, tenant_id, object_id, version)
1632
1667
  if target is None:
1633
1668
  raise AgentevoError(
@@ -1635,6 +1670,7 @@ def _managed_or_404(
1635
1670
  "unknown object version",
1636
1671
  {"object_id": object_id, "version": version},
1637
1672
  )
1673
+ require_workspace(principal, target.workspace_id)
1638
1674
  return target
1639
1675
 
1640
1676
 
@@ -1649,7 +1685,7 @@ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
1649
1685
  version = _body_version(body)
1650
1686
  environment = body.get("environment")
1651
1687
  parse_environment(environment)
1652
- _managed_or_404(conn, principal.tenant_id, object_id, version)
1688
+ target = _managed_or_404(conn, principal.tenant_id, object_id, version, principal)
1653
1689
  params = {} if environment is None else {"environment": environment}
1654
1690
 
1655
1691
  def compute() -> tuple[int, dict[str, Any]]:
@@ -1660,6 +1696,7 @@ def _verify_object( # noqa: PLR0913, PLR0917 - handler carries route context
1660
1696
  object_id=object_id,
1661
1697
  version=version,
1662
1698
  params=params,
1699
+ workspace_id=target.workspace_id,
1663
1700
  )
1664
1701
  audit(
1665
1702
  conn,
@@ -1685,7 +1722,7 @@ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route contex
1685
1722
  version = _body_version(body)
1686
1723
  environment = body.get("environment")
1687
1724
  parse_environment(environment)
1688
- target = _managed_or_404(conn, principal.tenant_id, object_id, version)
1725
+ target = _managed_or_404(conn, principal.tenant_id, object_id, version, principal)
1689
1726
  if target.state not in _REVALIDATABLE_STATES:
1690
1727
  raise AgentevoError(
1691
1728
  ErrorCode.CONFLICT,
@@ -1704,6 +1741,7 @@ def _revalidate_object( # noqa: PLR0913, PLR0917 - handler carries route contex
1704
1741
  object_id=object_id,
1705
1742
  version=version,
1706
1743
  params=params,
1744
+ workspace_id=target.workspace_id,
1707
1745
  )
1708
1746
  audit(
1709
1747
  conn,
@@ -1724,7 +1762,7 @@ def _get_object_version(
1724
1762
  principal: Principal = Depends(RECALL_READ),
1725
1763
  conn: sqlite3.Connection = Depends(get_conn),
1726
1764
  ) -> dict[str, Any]:
1727
- return _managed_or_404(conn, principal.tenant_id, object_id, version).to_dict()
1765
+ return _managed_or_404(conn, principal.tenant_id, object_id, version, principal).to_dict()
1728
1766
 
1729
1767
 
1730
1768
  def _body_reason(body: dict[str, Any]) -> RevocationReason:
@@ -1768,12 +1806,17 @@ def _compile_experiences(
1768
1806
  workspace_id = parse_required_str(body.get("workspace_id"), "workspace_id")
1769
1807
  project_id = parse_required_str(body.get("project_id", DEFAULT_COMPILE_PROJECT), "project_id")
1770
1808
  ensure_workspace(conn, principal.tenant_id, workspace_id)
1809
+ require_workspace(principal, workspace_id)
1771
1810
  require_searchable(conn, principal.tenant_id, workspace_id, "compile managed experiences")
1772
1811
  params = {"session_id": session_id, "workspace_id": workspace_id, "project_id": project_id}
1773
1812
 
1774
1813
  def compute() -> tuple[int, dict[str, Any]]:
1775
1814
  job = create_job(
1776
- conn, principal.tenant_id, JobType.COMPILE_EXPERIENCES.value, params=params
1815
+ conn,
1816
+ principal.tenant_id,
1817
+ JobType.COMPILE_EXPERIENCES.value,
1818
+ params=params,
1819
+ workspace_id=workspace_id,
1777
1820
  )
1778
1821
  audit(
1779
1822
  conn,
@@ -1801,7 +1844,7 @@ def _compile_workflows(
1801
1844
  parse_fallbacks(body.get("fallbacks", {}))
1802
1845
  canary = parse_canary_steps(body.get("canary_steps", 1))
1803
1846
  rows = [
1804
- _compile_skill_row(conn, principal.tenant_id, object_id, version)
1847
+ _compile_skill_row(conn, principal.tenant_id, object_id, version, principal)
1805
1848
  for object_id, version in refs
1806
1849
  ]
1807
1850
  if len({row.workspace_id for row in rows}) > 1:
@@ -1810,6 +1853,7 @@ def _compile_workflows(
1810
1853
  "cross-workspace compile rejected",
1811
1854
  {"workspaces": sorted({row.workspace_id for row in rows})},
1812
1855
  )
1856
+ require_workspace(principal, rows[0].workspace_id)
1813
1857
  require_searchable(conn, principal.tenant_id, rows[0].workspace_id, "compile managed workflows")
1814
1858
  params: dict[str, Any] = {
1815
1859
  "skills": [{"object_id": row.object_id, "version": row.version} for row in rows],
@@ -1820,7 +1864,13 @@ def _compile_workflows(
1820
1864
  }
1821
1865
 
1822
1866
  def compute() -> tuple[int, dict[str, Any]]:
1823
- job = create_job(conn, principal.tenant_id, JobType.COMPILE_WORKFLOW.value, params=params)
1867
+ job = create_job(
1868
+ conn,
1869
+ principal.tenant_id,
1870
+ JobType.COMPILE_WORKFLOW.value,
1871
+ params=params,
1872
+ workspace_id=rows[0].workspace_id,
1873
+ )
1824
1874
  audit(
1825
1875
  conn,
1826
1876
  principal.tenant_id,
@@ -1835,9 +1885,13 @@ def _compile_workflows(
1835
1885
 
1836
1886
 
1837
1887
  def _compile_skill_row(
1838
- conn: sqlite3.Connection, tenant_id: str, object_id: str, version: int
1888
+ conn: sqlite3.Connection,
1889
+ tenant_id: str,
1890
+ object_id: str,
1891
+ version: int,
1892
+ principal: Principal,
1839
1893
  ) -> ManagedObject:
1840
- """One referenced skill (404 unknown; 409 unless SKILL plus Trusted)."""
1894
+ """One referenced skill (404 unknown/out-of-scope; 409 unless SKILL plus Trusted)."""
1841
1895
  row = get_managed(conn, tenant_id, object_id, version)
1842
1896
  if row is None:
1843
1897
  raise AgentevoError(
@@ -1845,6 +1899,9 @@ def _compile_skill_row(
1845
1899
  "unknown object version",
1846
1900
  {"object_id": object_id, "version": version},
1847
1901
  )
1902
+ # Scope before substance: the 409s below must never confirm an
1903
+ # out-of-scope object's type or state to a scoped token.
1904
+ require_workspace(principal, row.workspace_id)
1848
1905
  if row.object_type != LearnedObjectType.SKILL.value:
1849
1906
  raise AgentevoError(
1850
1907
  ErrorCode.CONFLICT,
@@ -1908,6 +1965,8 @@ def _get_job(
1908
1965
  if job is None:
1909
1966
  raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1910
1967
  _require_job_scope(principal, job.job_type)
1968
+ if job.workspace_id is not None:
1969
+ require_workspace(principal, job.workspace_id)
1911
1970
  return job.to_dict()
1912
1971
 
1913
1972
 
@@ -1927,7 +1986,9 @@ def _list_jobs(
1927
1986
  raise AgentevoError(
1928
1987
  ErrorCode.INVALID_ARGUMENT, f"unknown job state: {state}", {"state": state}
1929
1988
  )
1930
- jobs = list_jobs(conn, principal.tenant_id, state=state, limit=limit)
1989
+ jobs = list_jobs(
1990
+ conn, principal.tenant_id, state=state, limit=limit, workspaces=principal.workspaces
1991
+ )
1931
1992
  return {"jobs": [job.to_dict() for job in jobs]}
1932
1993
 
1933
1994
 
@@ -1942,6 +2003,8 @@ def _cancel_job(
1942
2003
  if job is None:
1943
2004
  raise AgentevoError(ErrorCode.NOT_FOUND, "unknown job", {"job_id": job_id})
1944
2005
  _require_job_scope(principal, job.job_type)
2006
+ if job.workspace_id is not None:
2007
+ require_workspace(principal, job.workspace_id)
1945
2008
 
1946
2009
  def compute() -> tuple[int, dict[str, Any]]:
1947
2010
  canceled = cancel_job(conn, principal.tenant_id, job_id)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Control-plane operator CLI: tenant provisioning, serving, and job driving."""
2
3
 
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Cloud Control API settings (environment-driven, no secrets in code)."""
2
3
 
3
4
  from __future__ import annotations
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Control-plane storage: SQLite engine plus versioned migrations.
2
3
 
3
4
  The repository layer speaks plain SQL that stays portable to server RDBMS
@@ -16,7 +17,9 @@ from collections.abc import Callable, Iterator
16
17
  from contextlib import contextmanager, suppress
17
18
  from pathlib import Path
18
19
 
19
- SCHEMA_VERSION = 9
20
+ from agentevo_core.errors import AgentevoError, ErrorCode
21
+
22
+ SCHEMA_VERSION = 10
20
23
 
21
24
  #: Migration mutual exclusion. Five compose services share one SQLite file
22
25
  #: and migrate lazily on first use; without this, concurrent first-boots race
@@ -358,9 +361,46 @@ def _ensure_job_durability_columns(raw: sqlite3.Connection) -> None:
358
361
 
359
362
  _MIGRATION_FOLLOWUPS[8] = _ensure_job_durability_columns
360
363
 
361
- _SCHEMA_V9 = """
362
- CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_links_repo ON repo_links (repo);
363
- """
364
+ _SCHEMA_V10 = "-- V10: job workspace scoping column (applied by followup).\n"
365
+
366
+
367
+ def _ensure_job_workspace_column(raw: sqlite3.Connection) -> None:
368
+ """Add `jobs.workspace_id` unless a replay already did (NULL = legacy)."""
369
+ names = {row[1] for row in raw.execute("PRAGMA table_info(jobs)").fetchall()}
370
+ if "workspace_id" not in names:
371
+ raw.execute("ALTER TABLE jobs ADD COLUMN workspace_id TEXT")
372
+ raw.execute("CREATE INDEX IF NOT EXISTS idx_jobs_workspace ON jobs (tenant_id, workspace_id)")
373
+
374
+
375
+ _MIGRATION_FOLLOWUPS[10] = _ensure_job_workspace_column
376
+
377
+ _SCHEMA_V9 = "-- V9: globally-unique repo links (applied by followup).\n"
378
+
379
+
380
+ def _ensure_unique_repo_index(raw: sqlite3.Connection) -> None:
381
+ """Create the global repo uniqueness index, or fail with operator guidance.
382
+
383
+ Pre-V9 databases keyed links by (tenant, repo): two tenants may share
384
+ one name. The bare index would abort boot with a raw IntegrityError;
385
+ detect the collision first and name the repos so the operator can
386
+ unlink all but one and restart. Fail-closed either way.
387
+ """
388
+ dupes = [
389
+ row[0]
390
+ for row in raw.execute(
391
+ "SELECT repo FROM repo_links GROUP BY repo HAVING COUNT(DISTINCT tenant_id) > 1"
392
+ ).fetchall()
393
+ ]
394
+ if dupes:
395
+ raise AgentevoError(
396
+ ErrorCode.CONFLICT,
397
+ "cannot migrate: repo linked by multiple tenants; unlink all but one, then restart",
398
+ {"repos": sorted(dupes)},
399
+ )
400
+ raw.execute("CREATE UNIQUE INDEX IF NOT EXISTS uq_repo_links_repo ON repo_links (repo)")
401
+
402
+
403
+ _MIGRATION_FOLLOWUPS[9] = _ensure_unique_repo_index
364
404
 
365
405
  MIGRATIONS: tuple[tuple[int, str], ...] = (
366
406
  (1, _SCHEMA_V1),
@@ -372,6 +412,7 @@ MIGRATIONS: tuple[tuple[int, str], ...] = (
372
412
  (7, _SCHEMA_V7),
373
413
  (8, _SCHEMA_V8),
374
414
  (9, _SCHEMA_V9),
415
+ (10, _SCHEMA_V10),
375
416
  )
376
417
 
377
418
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Workspace devices and the OAuth device authorization flow (RFC 8628).
2
3
 
3
4
  Devices register public keys under TOFU: each ``(key_id, workspace)``
@@ -669,9 +670,11 @@ def _granted_workspace(
669
670
  {"workspace": requested},
670
671
  )
671
672
  if identity.workspaces and requested not in identity.workspaces:
673
+ # Same 404 as a missing workspace: a scoped approver must not learn
674
+ # whether the slug exists outside its grant (require_workspace rule).
672
675
  raise AgentevoError(
673
- ErrorCode.FORBIDDEN,
674
- "approver token is not valid for the requested workspace",
676
+ ErrorCode.NOT_FOUND,
677
+ f"unknown workspace: {requested}",
675
678
  {"workspace": requested},
676
679
  )
677
680
  return requested
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed experience ingest: sanitized C1/C2 event summaries.
2
3
 
3
4
  Spec: API §5/§7. ``POST /v1/events/batch`` carries up to 100 canonical
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Async job orchestration: durable verify/compile jobs with poll + webhooks.
2
3
 
3
4
  Spec: API §5/§9/§11/§23. Submissions return 202 with a queued job;
@@ -96,6 +97,7 @@ class Job:
96
97
  finished_at: str | None
97
98
  attempts: int = 0
98
99
  next_run_at: str | None = None
100
+ workspace_id: str | None = None
99
101
 
100
102
  def to_dict(self) -> dict[str, Any]:
101
103
  """Poll response shape (tenant omitted: derived server-side)."""
@@ -104,6 +106,7 @@ class Job:
104
106
  "type": self.job_type,
105
107
  "object_id": self.object_id,
106
108
  "version": self.version,
109
+ "workspace_id": self.workspace_id,
107
110
  "state": self.state,
108
111
  "params": self.params,
109
112
  "result": self.result,
@@ -156,8 +159,9 @@ def create_job( # noqa: PLR0913 - submission carries job context
156
159
  version: int | None = None,
157
160
  params: dict[str, Any] | None = None,
158
161
  now: datetime | None = None,
162
+ workspace_id: str | None = None,
159
163
  ) -> Job:
160
- """Queue one job (202 submission path)."""
164
+ """Queue one job (202 submission path; workspace stamps its scope)."""
161
165
  try:
162
166
  kind = JobType(job_type)
163
167
  except ValueError as exc:
@@ -169,7 +173,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
169
173
  payload = params or {}
170
174
  conn.execute(
171
175
  "INSERT INTO jobs (tenant_id, job_id, job_type, object_id, version,"
172
- " state, params, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
176
+ " state, params, created_at, workspace_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
173
177
  (
174
178
  tenant_id,
175
179
  job_id,
@@ -179,6 +183,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
179
183
  JobState.QUEUED.value,
180
184
  _dump(payload),
181
185
  moment,
186
+ workspace_id,
182
187
  ),
183
188
  )
184
189
  return Job(
@@ -194,6 +199,7 @@ def create_job( # noqa: PLR0913 - submission carries job context
194
199
  created_at=moment,
195
200
  started_at=None,
196
201
  finished_at=None,
202
+ workspace_id=workspace_id,
197
203
  )
198
204
 
199
205
 
@@ -206,23 +212,34 @@ def get_job(conn: sqlite3.Connection, tenant_id: str, job_id: str) -> Job | None
206
212
 
207
213
 
208
214
  def list_jobs(
209
- conn: sqlite3.Connection, tenant_id: str, *, state: str | None = None, limit: int = 50
215
+ conn: sqlite3.Connection,
216
+ tenant_id: str,
217
+ *,
218
+ state: str | None = None,
219
+ limit: int = 50,
220
+ workspaces: tuple[str, ...] = (),
210
221
  ) -> list[Job]:
211
- """Tenant jobs, newest first (operator listing)."""
222
+ """Tenant jobs, newest first (operator listing; scope-filtered)."""
212
223
  if not 1 <= limit <= LIST_JOBS_MAX:
213
224
  raise AgentevoError(
214
225
  ErrorCode.INVALID_ARGUMENT, f"limit must be within [1, {LIST_JOBS_MAX}]", {}
215
226
  )
216
- if state is None:
217
- rows = conn.execute(
218
- "SELECT * FROM jobs WHERE tenant_id = ? ORDER BY created_at DESC LIMIT ?",
219
- (tenant_id, limit),
220
- ).fetchall()
221
- else:
222
- rows = conn.execute(
223
- "SELECT * FROM jobs WHERE tenant_id = ? AND state = ? ORDER BY created_at DESC LIMIT ?",
224
- (tenant_id, state, limit),
225
- ).fetchall()
227
+ clauses = ["tenant_id = ?"]
228
+ params: list[Any] = [tenant_id]
229
+ if state is not None:
230
+ clauses.append("state = ?")
231
+ params.append(state)
232
+ if workspaces:
233
+ placeholders = ", ".join("?" for _ in workspaces)
234
+ clauses.append(f"workspace_id IN ({placeholders})")
235
+ params.extend(workspaces)
236
+ query = (
237
+ "SELECT * FROM jobs WHERE " # noqa: S608 - static fragments + binds
238
+ + " AND ".join(clauses)
239
+ + " ORDER BY created_at DESC LIMIT ?"
240
+ )
241
+ params.append(limit)
242
+ rows = conn.execute(query, params).fetchall()
226
243
  return [_job_row(row) for row in rows]
227
244
 
228
245
 
@@ -546,6 +563,8 @@ def _job_row(row: sqlite3.Row) -> Job:
546
563
  finished_at=row["finished_at"],
547
564
  attempts=row["attempts"],
548
565
  next_run_at=row["next_run_at"],
566
+ # NOTE: sqlite3.Row `in` scans values, so `.keys()` is required here.
567
+ workspace_id=row["workspace_id"] if "workspace_id" in row.keys() else None, # noqa: SIM118
549
568
  )
550
569
 
551
570
 
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed C2 registry: tenant-partitioned index over ingested sync deltas.
2
3
 
3
4
  Spec: API §8/§12, Data Flow §7 steps 5-7. Push ingest stores the
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Request metering: quotas, monthly cycles, burst rate limits.
2
3
 
3
4
  Exactly the spec's billable set is metered: authenticated platform
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """OIDC/JWT verification shared by CI exchange and browser device approval.
2
3
 
3
4
  RS256 via the issuer JWKS is the production path (GitHub Actions for CI,
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """OpenAPI snapshot: regenerate the checked-in v1 contract (API §25).
2
3
 
3
4
  The checked-in ``openapi/openapi.v1.json`` is generated from the live app,
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed promotion: Candidate -> Verified -> Trusted advancement.
2
3
 
3
4
  Spec: PRD §17/§19, API §20. Mirrors the local PromotionService gates over
@@ -24,7 +25,7 @@ from datetime import UTC, datetime
24
25
  from agentevo_cloud.jobs import JobState, JobType
25
26
  from agentevo_cloud.managed import ManagedObject, get_managed
26
27
  from agentevo_cloud.records import audit
27
- from agentevo_cloud.tokens import Principal
28
+ from agentevo_cloud.tokens import Principal, require_workspace
28
29
  from agentevo_core.errors import AgentevoError, ErrorCode
29
30
  from agentevo_core.risk import RiskClass, requires_human_approval
30
31
  from agentevo_core.sync import DeltaKind
@@ -75,6 +76,7 @@ def promote_managed( # noqa: PLR0913 - promotion carries object context
75
76
  "unknown object version",
76
77
  {"object_id": object_id, "version": version},
77
78
  )
79
+ require_workspace(principal, row.workspace_id)
78
80
  check_transition(TrustState(row.state), target)
79
81
  moment = now or datetime.now(UTC)
80
82
  tally = _tally(conn, principal.tenant_id, object_id, version)
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed recall: tenant-scoped retrieval over the C2 index.
2
3
 
3
4
  Spec: API §8/§13. The server authenticates, derives tenant scope,
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Append-only audit log (BR-014) and idempotency-key store (API §15).
2
3
 
3
4
  Audit details carry C1 labels only — never request bodies, secrets, or
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Server-attested revocation: object-wide tombstones with full propagation.
2
3
 
3
4
  Spec: Data Flow §13, API §20. Revocation is the safety override: one
@@ -35,7 +36,7 @@ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
35
36
  from agentevo_cloud.managed import index_accepted
36
37
  from agentevo_cloud.records import audit
37
38
  from agentevo_cloud.sync import store_server_delta
38
- from agentevo_cloud.tokens import Principal
39
+ from agentevo_cloud.tokens import Principal, require_workspace
39
40
  from agentevo_core.classification import Classification
40
41
  from agentevo_core.errors import AgentevoError, ErrorCode
41
42
  from agentevo_core.objects import LearnedObjectType
@@ -95,6 +96,8 @@ def revoke_object(
95
96
  ).fetchall()
96
97
  if not rows:
97
98
  raise AgentevoError(ErrorCode.NOT_FOUND, "unknown object", {"object_id": object_id})
99
+ for row in rows:
100
+ require_workspace(principal, row["workspace_id"])
98
101
  live = [row for row in rows if row["state"] not in _TERMINAL_REVOKE_STATES]
99
102
  moment = now or datetime.now(UTC)
100
103
  actor = principal.user_id or principal.token_id
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Second-pass ingest screening: classification, C4 patterns, C3 evacuation.
2
3
 
3
4
  Spec: API §12/§17/§25, Data Flow §7. The local runtime minimizes and
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Managed sync ingest: push validation, ordered store, pull pages.
2
3
 
3
4
  Spec: API §5/§12/§20. This module mirrors the ``LoopbackTransport``
@@ -1,3 +1,4 @@
1
+ # SPDX-License-Identifier: BUSL-1.1
1
2
  """Tenants, memberships, workspaces, and plan entitlements.
2
3
 
3
4
  Plans: Free carries exactly 10,000 billable requests per monthly cycle