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.
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/LICENSE +1 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/PKG-INFO +8 -2
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/README.md +6 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/pyproject.toml +1 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/__init__.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/analytics.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/app.py +76 -13
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/cli.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/config.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/db.py +45 -4
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/devices.py +5 -2
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/events.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/jobs.py +33 -14
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/managed.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/metering.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/oidc.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/openapi.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/promote.py +3 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/recall.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/records.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/revoke.py +4 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/screen.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/sync.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/tenants.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/tokens.py +59 -4
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/webhooks.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/src/agentevo_cloud/workers.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_acceptance_m12.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_acceptance_m8.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_analytics.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_auth.py +127 -4
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_jobs.py +61 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_managed.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_objects.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_app_sync.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_body_limit.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_cli.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_compile.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_devices_ci.py +2 -1
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_events.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_foundation.py +85 -10
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_jobs.py +59 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_maintenance.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_managed.py +4 -3
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_metering_records.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_oidc.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_openapi.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_promote.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_recall.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_revoke.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_screen.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_smoke.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_sync_mode.py +30 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_sync_push_pull.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_webhooks.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/tests/test_workers.py +1 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/.gitignore +0 -0
- {agentevo_cloud-0.1.2 → agentevo_cloud-0.1.4}/openapi/openapi.v1.json +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentevo-cloud
|
|
3
|
-
Version: 0.1.
|
|
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.
|
|
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,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,
|
|
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
|
|
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,
|
|
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(
|
|
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,
|
|
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(
|
|
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 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
|
-
|
|
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
|
-
|
|
362
|
-
|
|
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.
|
|
674
|
-
"
|
|
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
|
"""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,
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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 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
|
"""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
|