gumloop 0.3.5__tar.gz → 0.4.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.
- {gumloop-0.3.5 → gumloop-0.4.2}/PKG-INFO +21 -1
- {gumloop-0.3.5 → gumloop-0.4.2}/README.md +20 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/pyproject.toml +2 -1
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/__init__.py +2 -1
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/_client.py +11 -1
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/_http.py +98 -4
- gumloop-0.4.2/src/gumloop/_version.py +3 -0
- gumloop-0.4.2/src/gumloop/cli/commands/_sync_output.py +328 -0
- gumloop-0.4.2/src/gumloop/cli/commands/sync.py +335 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/main.py +2 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/__init__.py +2 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/agents.py +10 -4
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/mcp.py +8 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/skills.py +4 -0
- gumloop-0.4.2/src/gumloop/resources/sync.py +94 -0
- gumloop-0.4.2/src/gumloop/sync/__init__.py +1 -0
- gumloop-0.4.2/src/gumloop/sync/archive.py +131 -0
- gumloop-0.4.2/src/gumloop/sync/bundle.py +71 -0
- gumloop-0.4.2/src/gumloop/sync/bundle_validation.py +121 -0
- gumloop-0.4.2/src/gumloop/sync/errors.py +20 -0
- gumloop-0.4.2/src/gumloop/sync/hashing.py +65 -0
- gumloop-0.4.2/src/gumloop/sync/installed.py +141 -0
- gumloop-0.4.2/src/gumloop/sync/local_state.py +161 -0
- gumloop-0.4.2/src/gumloop/sync/lock.py +50 -0
- gumloop-0.4.2/src/gumloop/sync/markers.py +159 -0
- gumloop-0.4.2/src/gumloop/sync/reconcile.py +219 -0
- gumloop-0.4.2/src/gumloop/sync/reporting.py +136 -0
- gumloop-0.4.2/src/gumloop/sync/results.py +67 -0
- gumloop-0.4.2/src/gumloop/sync/run.py +556 -0
- gumloop-0.4.2/src/gumloop/sync/scheduler.py +294 -0
- gumloop-0.4.2/src/gumloop/sync/target_files.py +421 -0
- gumloop-0.4.2/src/gumloop/sync/targets.py +63 -0
- gumloop-0.4.2/src/gumloop/sync/wire.py +15 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/types.py +67 -0
- gumloop-0.4.2/tests/cli/__init__.py +1 -0
- gumloop-0.4.2/tests/cli/conftest.py +116 -0
- gumloop-0.4.2/tests/cli/sync_scenario_helpers.py +342 -0
- gumloop-0.4.2/tests/cli/sync_test_fakes.py +85 -0
- gumloop-0.4.2/tests/cli/test_home_isolation.py +49 -0
- gumloop-0.4.2/tests/cli/test_sync_bundle.py +359 -0
- gumloop-0.4.2/tests/cli/test_sync_command.py +744 -0
- gumloop-0.4.2/tests/cli/test_sync_command_safety.py +371 -0
- gumloop-0.4.2/tests/cli/test_sync_contract_fixtures.py +187 -0
- gumloop-0.4.2/tests/cli/test_sync_enrollment.py +671 -0
- gumloop-0.4.2/tests/cli/test_sync_fixture_corpus.py +32 -0
- gumloop-0.4.2/tests/cli/test_sync_hashing.py +65 -0
- gumloop-0.4.2/tests/cli/test_sync_installed.py +250 -0
- gumloop-0.4.2/tests/cli/test_sync_reconciliation_safety.py +206 -0
- gumloop-0.4.2/tests/cli/test_sync_scheduler_macos.py +350 -0
- gumloop-0.4.2/tests/cli/test_sync_targets.py +217 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/README.md +19 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/generated-bundle-transfer-limit.json +10 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-absolute-path.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-bytes-per-file-limit.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-device.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-duplicate-entry.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-fifo.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-files-per-skill-limit.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-nul-name.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-parent-traversal.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-reserved-marker.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-socket.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-symlink.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-total-uncompressed-limit.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/invalid-windows-drive-prefix.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/valid-single-skill-stored.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/archives/valid-single-skill.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/bundles/newer-than-plan.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/bundles/normal.zip +0 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/catalog.json +179 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/corpus.sha256 +1 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/hashes/content-v1.json +331 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/hashes/manifest-v1.json +130 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-identity.json +32 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/invalid-duplicate-install-name.json +32 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/invalid-manifest-hash.json +26 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/newer-than-plan.json +26 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/manifests/normal-bundle.json +26 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/below-pro.json +9 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/cli-upgrade-required.json +12 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/lost-membership.json +9 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/normal-empty-plan.json +19 -0
- gumloop-0.4.2/tests/fixtures/skill_sync/v1/responses/normal-plan.json +19 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_client.py +121 -0
- gumloop-0.4.2/tests/sdk/test_sync.py +309 -0
- gumloop-0.4.2/tests/skill_sync_fixtures.py +35 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/uv.lock +1 -1
- gumloop-0.3.5/tests/cli/__init__.py +0 -0
- gumloop-0.3.5/tests/cli/conftest.py +0 -55
- {gumloop-0.3.5 → gumloop-0.4.2}/.env.example +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/.gitignore +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/.pre-commit-config.yaml +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/.python-version +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/LICENSE +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/scripts/check +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/scripts/fmt +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/scripts/sdk_examples.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/_auth.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/__init__.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/__init__.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/_args_input.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/_downloads.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/agents.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/artifacts.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/auth.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/brain.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/chat.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/mcp.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/sessions.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/commands/skills.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/console.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/context.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/credentials.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/errors.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/cli/oauth.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/errors.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/oauth.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/py.typed +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/artifacts.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/brain.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/chat.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/sessions.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/resources/teams.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/spec/__init__.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/spec/_compat.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/src/gumloop/spec/_extensions.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/__init__.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_agents.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_args_input.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_artifacts.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_brain.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_chat.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_context.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_credentials.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_login.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_main.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_mcp.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_sessions.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/cli/test_skills.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/conftest.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/integration/conftest.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/integration/test_live.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/__init__.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/conftest.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/helpers.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_agents.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_artifacts.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_brain.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_chat.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_mcp.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_oauth.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_sessions.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_skills.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/sdk/test_teams.py +0 -0
- {gumloop-0.3.5 → gumloop-0.4.2}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gumloop
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.2
|
|
4
4
|
Summary: Python client for the Gumloop API
|
|
5
5
|
Project-URL: Homepage, https://github.com/gumloop/gumloop-py
|
|
6
6
|
Project-URL: Bug Tracker, https://github.com/gumloop/gumloop-py/issues
|
|
@@ -57,6 +57,26 @@ output = client.run_flow(
|
|
|
57
57
|
print(output)
|
|
58
58
|
```
|
|
59
59
|
|
|
60
|
+
## Authenticate with a team API key
|
|
61
|
+
|
|
62
|
+
Team (workspace) API keys are scoped to a single team. Pass the team's ID and
|
|
63
|
+
the acting member's user ID — every request is validated against that team and
|
|
64
|
+
only reaches resources the team owns.
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from gumloop import Gumloop
|
|
68
|
+
|
|
69
|
+
client = Gumloop(
|
|
70
|
+
api_key="your_team_api_key",
|
|
71
|
+
user_id="your_user_id", # must be a member of the team
|
|
72
|
+
team_id="your_team_id",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
agents = client.agents.list() # scoped to the team
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
`team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
|
|
79
|
+
|
|
60
80
|
## Chat with an agent (streaming)
|
|
61
81
|
|
|
62
82
|
```python
|
|
@@ -32,6 +32,26 @@ output = client.run_flow(
|
|
|
32
32
|
print(output)
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
+
## Authenticate with a team API key
|
|
36
|
+
|
|
37
|
+
Team (workspace) API keys are scoped to a single team. Pass the team's ID and
|
|
38
|
+
the acting member's user ID — every request is validated against that team and
|
|
39
|
+
only reaches resources the team owns.
|
|
40
|
+
|
|
41
|
+
```python
|
|
42
|
+
from gumloop import Gumloop
|
|
43
|
+
|
|
44
|
+
client = Gumloop(
|
|
45
|
+
api_key="your_team_api_key",
|
|
46
|
+
user_id="your_user_id", # must be a member of the team
|
|
47
|
+
team_id="your_team_id",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
agents = client.agents.list() # scoped to the team
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`team_id` can also be provided via the `GUMLOOP_TEAM_ID` environment variable.
|
|
54
|
+
|
|
35
55
|
## Chat with an agent (streaming)
|
|
36
56
|
|
|
37
57
|
```python
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "gumloop"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Python client for the Gumloop API"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -60,6 +60,7 @@ build = [
|
|
|
60
60
|
testpaths = ["tests"]
|
|
61
61
|
markers = [
|
|
62
62
|
"live: hits the real Gumloop API and upstream providers; opt-in via `-m live`",
|
|
63
|
+
"implementation: asserts internals or invariants beyond the public contract",
|
|
63
64
|
]
|
|
64
65
|
|
|
65
66
|
[tool.ruff]
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
from gumloop._client import AsyncGumloop
|
|
2
2
|
from gumloop._client import Gumloop
|
|
3
3
|
from gumloop._client import GumloopClient
|
|
4
|
+
from gumloop._version import __version__
|
|
4
5
|
from gumloop.errors import APIStatusError
|
|
5
6
|
from gumloop.errors import AuthenticationError
|
|
6
7
|
from gumloop.errors import GumloopError
|
|
7
8
|
from gumloop.oauth import OAuth
|
|
8
9
|
|
|
9
|
-
__version__ = "0.3.5"
|
|
10
10
|
__all__ = [
|
|
11
11
|
"APIStatusError",
|
|
12
12
|
"AsyncGumloop",
|
|
@@ -15,4 +15,5 @@ __all__ = [
|
|
|
15
15
|
"GumloopClient",
|
|
16
16
|
"GumloopError",
|
|
17
17
|
"OAuth",
|
|
18
|
+
"__version__",
|
|
18
19
|
]
|
|
@@ -28,10 +28,11 @@ from gumloop.resources import Chat
|
|
|
28
28
|
from gumloop.resources import Models
|
|
29
29
|
from gumloop.resources import Sessions
|
|
30
30
|
from gumloop.resources import Skills
|
|
31
|
+
from gumloop.resources import Sync
|
|
31
32
|
from gumloop.resources import Teams
|
|
32
33
|
|
|
33
34
|
DEFAULT_BASE_URL = "https://api.gumloop.com/api/v1"
|
|
34
|
-
DEFAULT_TIMEOUT =
|
|
35
|
+
DEFAULT_TIMEOUT = 90.0
|
|
35
36
|
DEFAULT_STREAM_TIMEOUT = 3600.0
|
|
36
37
|
|
|
37
38
|
|
|
@@ -54,6 +55,7 @@ class Gumloop:
|
|
|
54
55
|
*,
|
|
55
56
|
access_token: str | None = None,
|
|
56
57
|
user_id: str | None = None,
|
|
58
|
+
team_id: str | None = None,
|
|
57
59
|
base_url: str | None = None,
|
|
58
60
|
stream_base_url: str | None = None,
|
|
59
61
|
timeout: float = DEFAULT_TIMEOUT,
|
|
@@ -65,6 +67,8 @@ class Gumloop:
|
|
|
65
67
|
# x-auth-key header to look up the owner's stored secret. OAuth
|
|
66
68
|
# callers omit it because the bearer token already identifies them.
|
|
67
69
|
self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
|
|
70
|
+
# Team API keys are validated against ``team_id``; ``user_id`` stays the acting member.
|
|
71
|
+
self.team_id = team_id or os.environ.get("GUMLOOP_TEAM_ID")
|
|
68
72
|
# Defaults to production; GUMLOOP_BASE_URL is an optional override.
|
|
69
73
|
self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
70
74
|
self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
|
|
@@ -76,6 +80,7 @@ class Gumloop:
|
|
|
76
80
|
stream_base_url=self.stream_base_url,
|
|
77
81
|
access_token=self.access_token,
|
|
78
82
|
user_id=self.user_id,
|
|
83
|
+
team_id=self.team_id,
|
|
79
84
|
timeout=self.timeout,
|
|
80
85
|
stream_timeout=self.stream_timeout,
|
|
81
86
|
)
|
|
@@ -87,6 +92,7 @@ class Gumloop:
|
|
|
87
92
|
self.mcp = MCP(self._http)
|
|
88
93
|
self.teams = Teams(self._http)
|
|
89
94
|
self.skills = Skills(self._http)
|
|
95
|
+
self.sync = Sync(self._http)
|
|
90
96
|
self.artifacts = Artifacts(self._http)
|
|
91
97
|
self.brain = Brain(self._http)
|
|
92
98
|
self.oauth = OAuth(base_url=self.base_url, timeout=self.timeout)
|
|
@@ -110,6 +116,7 @@ class AsyncGumloop:
|
|
|
110
116
|
*,
|
|
111
117
|
access_token: str | None = None,
|
|
112
118
|
user_id: str | None = None,
|
|
119
|
+
team_id: str | None = None,
|
|
113
120
|
base_url: str | None = None,
|
|
114
121
|
stream_base_url: str | None = None,
|
|
115
122
|
timeout: float = DEFAULT_TIMEOUT,
|
|
@@ -118,6 +125,8 @@ class AsyncGumloop:
|
|
|
118
125
|
self.api_key = api_key
|
|
119
126
|
self.access_token = access_token or api_key or EnvToken.from_env()
|
|
120
127
|
self.user_id = user_id or os.environ.get("GUMLOOP_USER_ID")
|
|
128
|
+
# Team API keys are validated against ``team_id``; ``user_id`` stays the acting member.
|
|
129
|
+
self.team_id = team_id or os.environ.get("GUMLOOP_TEAM_ID")
|
|
121
130
|
# Defaults to production; GUMLOOP_BASE_URL is an optional override.
|
|
122
131
|
self.base_url = (base_url or os.environ.get("GUMLOOP_BASE_URL") or DEFAULT_BASE_URL).rstrip("/")
|
|
123
132
|
self.stream_base_url = (stream_base_url or _derive_stream_base_url(self.base_url)).rstrip("/")
|
|
@@ -129,6 +138,7 @@ class AsyncGumloop:
|
|
|
129
138
|
stream_base_url=self.stream_base_url,
|
|
130
139
|
access_token=self.access_token,
|
|
131
140
|
user_id=self.user_id,
|
|
141
|
+
team_id=self.team_id,
|
|
132
142
|
timeout=self.timeout,
|
|
133
143
|
stream_timeout=self.stream_timeout,
|
|
134
144
|
)
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import logging
|
|
4
|
+
import os
|
|
4
5
|
from collections.abc import AsyncIterator
|
|
5
6
|
from collections.abc import Iterator
|
|
6
7
|
from collections.abc import Mapping
|
|
@@ -19,10 +20,37 @@ from gumloop.types import StreamEvent
|
|
|
19
20
|
|
|
20
21
|
logger = logging.getLogger(__name__)
|
|
21
22
|
|
|
23
|
+
|
|
24
|
+
def _inject_trace_context(request: httpx.Request) -> None:
|
|
25
|
+
"""Request event hook: attach W3C trace context when the host sets TRACEPARENT.
|
|
26
|
+
|
|
27
|
+
Read per request: sandbox runtimes rotate env between calls. setdefault so
|
|
28
|
+
an explicitly provided header wins.
|
|
29
|
+
"""
|
|
30
|
+
traceparent = os.environ.get("TRACEPARENT")
|
|
31
|
+
if not traceparent:
|
|
32
|
+
return
|
|
33
|
+
request.headers.setdefault("traceparent", traceparent)
|
|
34
|
+
tracestate = os.environ.get("TRACESTATE")
|
|
35
|
+
if tracestate:
|
|
36
|
+
request.headers.setdefault("tracestate", tracestate)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _inject_trace_context_async(request: httpx.Request) -> None:
|
|
40
|
+
_inject_trace_context(request)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_TRACE_EVENT_HOOKS = {"request": [_inject_trace_context]}
|
|
44
|
+
_ASYNC_TRACE_EVENT_HOOKS = {"request": [_inject_trace_context_async]}
|
|
45
|
+
|
|
22
46
|
_DONE_SENTINEL = "[DONE]"
|
|
23
47
|
_T = TypeVar("_T", bound=BaseModel)
|
|
24
48
|
|
|
25
49
|
|
|
50
|
+
class ResponseSizeExceededError(Exception):
|
|
51
|
+
"""Raised when a streamed response body exceeds the caller's byte limit."""
|
|
52
|
+
|
|
53
|
+
|
|
26
54
|
def _omit_none_params(params: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
|
27
55
|
# Backend treats absent ``?foo`` and ``?foo=`` as different signals
|
|
28
56
|
# ("not provided" vs "empty string"); drop None values so the wire URL
|
|
@@ -60,14 +88,24 @@ class HttpClient:
|
|
|
60
88
|
stream_base_url: str,
|
|
61
89
|
access_token: str | None,
|
|
62
90
|
user_id: str | None,
|
|
91
|
+
team_id: str | None = None,
|
|
63
92
|
timeout: float,
|
|
64
93
|
stream_timeout: float | None,
|
|
65
94
|
) -> None:
|
|
66
95
|
self.access_token = access_token
|
|
67
96
|
self.user_id = user_id
|
|
97
|
+
self.team_id = team_id
|
|
68
98
|
self._stream_base_url = stream_base_url.rstrip("/")
|
|
69
99
|
self._stream_timeout = stream_timeout
|
|
70
|
-
self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout)
|
|
100
|
+
self._client = httpx.Client(base_url=base_url.rstrip("/"), timeout=timeout, event_hooks=_TRACE_EVENT_HOOKS)
|
|
101
|
+
|
|
102
|
+
def _scoped_params(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
103
|
+
# Team keys are validated against ``team_id``, so it rides on every request.
|
|
104
|
+
if not self.team_id:
|
|
105
|
+
return params
|
|
106
|
+
merged = dict(params or {})
|
|
107
|
+
merged.setdefault("team_id", self.team_id)
|
|
108
|
+
return merged
|
|
71
109
|
|
|
72
110
|
def close(self) -> None:
|
|
73
111
|
self._client.close()
|
|
@@ -88,8 +126,16 @@ class HttpClient:
|
|
|
88
126
|
json: Any = None,
|
|
89
127
|
data: Mapping[str, Any] | None = None,
|
|
90
128
|
files: list[tuple[str, Any]] | None = None,
|
|
129
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
91
130
|
) -> Any:
|
|
92
|
-
return self._request(
|
|
131
|
+
return self._request(
|
|
132
|
+
"POST",
|
|
133
|
+
path,
|
|
134
|
+
json=json,
|
|
135
|
+
data=_omit_none_params(data),
|
|
136
|
+
files=files,
|
|
137
|
+
extra_headers=extra_headers,
|
|
138
|
+
)
|
|
93
139
|
|
|
94
140
|
def patch(
|
|
95
141
|
self,
|
|
@@ -107,6 +153,31 @@ class HttpClient:
|
|
|
107
153
|
def delete(self, path: str) -> Any:
|
|
108
154
|
return self._request("DELETE", path)
|
|
109
155
|
|
|
156
|
+
def post_bytes(
|
|
157
|
+
self,
|
|
158
|
+
path: str,
|
|
159
|
+
*,
|
|
160
|
+
json: Any = None,
|
|
161
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
162
|
+
max_bytes: int,
|
|
163
|
+
) -> tuple[bytes, httpx.Headers]:
|
|
164
|
+
headers = auth_headers(self.access_token, self.user_id)
|
|
165
|
+
headers["Content-Type"] = "application/json"
|
|
166
|
+
if extra_headers:
|
|
167
|
+
headers.update(extra_headers)
|
|
168
|
+
with self._client.stream("POST", path, headers=headers, json=json) as response:
|
|
169
|
+
if response.status_code >= 400:
|
|
170
|
+
response.read()
|
|
171
|
+
raise to_api_error(response)
|
|
172
|
+
chunks: list[bytes] = []
|
|
173
|
+
total = 0
|
|
174
|
+
for chunk in response.iter_bytes():
|
|
175
|
+
total += len(chunk)
|
|
176
|
+
if total > max_bytes:
|
|
177
|
+
raise ResponseSizeExceededError(f"response body exceeds {max_bytes} bytes")
|
|
178
|
+
chunks.append(chunk)
|
|
179
|
+
return b"".join(chunks), response.headers
|
|
180
|
+
|
|
110
181
|
def post_to_stream_host(self, path: str, *, json: Any = None) -> Any:
|
|
111
182
|
# Endpoints whose streaming variant lives on the stream host (e.g. chat
|
|
112
183
|
# completions) must accept their unary counterparts at the same host —
|
|
@@ -182,12 +253,22 @@ class HttpClient:
|
|
|
182
253
|
logger.debug("dropped non-%s SSE: %s", response_model.__name__, event.data)
|
|
183
254
|
continue
|
|
184
255
|
|
|
185
|
-
def _request(
|
|
256
|
+
def _request(
|
|
257
|
+
self,
|
|
258
|
+
method: str,
|
|
259
|
+
path: str,
|
|
260
|
+
*,
|
|
261
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
262
|
+
**kwargs: Any,
|
|
263
|
+
) -> Any:
|
|
186
264
|
# Headers are rebuilt per request so ``access_token`` / ``user_id``
|
|
187
265
|
# can be rotated on a live client without reconstructing it.
|
|
188
266
|
headers = auth_headers(self.access_token, self.user_id)
|
|
189
267
|
if not kwargs.get("files"):
|
|
190
268
|
headers["Content-Type"] = "application/json"
|
|
269
|
+
if extra_headers:
|
|
270
|
+
headers.update(extra_headers)
|
|
271
|
+
kwargs["params"] = self._scoped_params(kwargs.get("params"))
|
|
191
272
|
response = self._client.request(method, path, headers=headers, **kwargs)
|
|
192
273
|
if response.status_code >= 400:
|
|
193
274
|
raise to_api_error(response)
|
|
@@ -204,14 +285,26 @@ class AsyncHttpClient:
|
|
|
204
285
|
stream_base_url: str,
|
|
205
286
|
access_token: str | None,
|
|
206
287
|
user_id: str | None,
|
|
288
|
+
team_id: str | None = None,
|
|
207
289
|
timeout: float,
|
|
208
290
|
stream_timeout: float | None,
|
|
209
291
|
) -> None:
|
|
210
292
|
self.access_token = access_token
|
|
211
293
|
self.user_id = user_id
|
|
294
|
+
self.team_id = team_id
|
|
212
295
|
self._stream_base_url = stream_base_url.rstrip("/")
|
|
213
296
|
self._stream_timeout = stream_timeout
|
|
214
|
-
self._client = httpx.AsyncClient(
|
|
297
|
+
self._client = httpx.AsyncClient(
|
|
298
|
+
base_url=base_url.rstrip("/"), timeout=timeout, event_hooks=_ASYNC_TRACE_EVENT_HOOKS
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
def _scoped_params(self, params: dict[str, Any] | None) -> dict[str, Any] | None:
|
|
302
|
+
# Team keys are validated against ``team_id``, so it rides on every request.
|
|
303
|
+
if not self.team_id:
|
|
304
|
+
return params
|
|
305
|
+
merged = dict(params or {})
|
|
306
|
+
merged.setdefault("team_id", self.team_id)
|
|
307
|
+
return merged
|
|
215
308
|
|
|
216
309
|
async def aclose(self) -> None:
|
|
217
310
|
await self._client.aclose()
|
|
@@ -325,6 +418,7 @@ class AsyncHttpClient:
|
|
|
325
418
|
headers = auth_headers(self.access_token, self.user_id)
|
|
326
419
|
if not kwargs.get("files"):
|
|
327
420
|
headers["Content-Type"] = "application/json"
|
|
421
|
+
kwargs["params"] = self._scoped_params(kwargs.get("params"))
|
|
328
422
|
response = await self._client.request(method, path, headers=headers, **kwargs)
|
|
329
423
|
if response.status_code >= 400:
|
|
330
424
|
raise to_api_error(response)
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections import Counter
|
|
4
|
+
from types import TracebackType
|
|
5
|
+
|
|
6
|
+
from rich.markup import escape as escape_markup
|
|
7
|
+
from rich.status import Status
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
from gumloop.cli.console import console
|
|
12
|
+
from gumloop.cli.console import error_console
|
|
13
|
+
from gumloop.sync.results import SyncExecution
|
|
14
|
+
from gumloop.sync.results import SyncProgress
|
|
15
|
+
from gumloop.sync.results import TargetOutcome
|
|
16
|
+
from gumloop.sync.targets import PhysicalTarget
|
|
17
|
+
from gumloop.types import CliSyncPlanResponse
|
|
18
|
+
|
|
19
|
+
_ACTION_LABELS = {
|
|
20
|
+
"adopted": "adopted",
|
|
21
|
+
"failed": "failed",
|
|
22
|
+
"installed": "installed",
|
|
23
|
+
"overwritten_collision": "replaced",
|
|
24
|
+
"overwritten_local_edit": "replaced local edit",
|
|
25
|
+
"removed": "removed",
|
|
26
|
+
"unchanged": "unchanged",
|
|
27
|
+
"updated": "updated",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_CRITICAL_ACTIONS = frozenset(
|
|
31
|
+
{
|
|
32
|
+
"failed",
|
|
33
|
+
"overwritten_collision",
|
|
34
|
+
"overwritten_local_edit",
|
|
35
|
+
"removed",
|
|
36
|
+
}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Human labels match Homecrew/crew adapter names (hyphenated).
|
|
40
|
+
_AGENT_LABELS = {
|
|
41
|
+
"agent_skills": "agent-skills",
|
|
42
|
+
"claude_code": "claude-code",
|
|
43
|
+
"codex": "codex",
|
|
44
|
+
"cursor": "cursor",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class SyncOutput:
|
|
49
|
+
def __init__(self) -> None:
|
|
50
|
+
self._status: Status | None = None
|
|
51
|
+
self._already_current = False
|
|
52
|
+
|
|
53
|
+
def __enter__(self) -> SyncOutput:
|
|
54
|
+
return self
|
|
55
|
+
|
|
56
|
+
def __exit__(
|
|
57
|
+
self,
|
|
58
|
+
_exc_type: type[BaseException] | None,
|
|
59
|
+
_exc_value: BaseException | None,
|
|
60
|
+
_traceback: TracebackType | None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._stop()
|
|
63
|
+
|
|
64
|
+
def __call__(self, progress: SyncProgress) -> None:
|
|
65
|
+
if progress.stage == "resolving_plan":
|
|
66
|
+
if progress.attempt == 1:
|
|
67
|
+
message = "Resolving managed Skills…"
|
|
68
|
+
else:
|
|
69
|
+
max_attempts = progress.max_attempts
|
|
70
|
+
retry_label = (
|
|
71
|
+
f"{progress.attempt}/{max_attempts}" if max_attempts is not None else str(progress.attempt)
|
|
72
|
+
)
|
|
73
|
+
message = f"Desired state changed; retrying ({retry_label})…"
|
|
74
|
+
self._start(message)
|
|
75
|
+
return
|
|
76
|
+
if progress.stage == "plan_resolved":
|
|
77
|
+
self._stop()
|
|
78
|
+
skill_count = progress.skill_count or 0
|
|
79
|
+
agent_count = progress.agent_count or 0
|
|
80
|
+
console.print(
|
|
81
|
+
"[green]✓[/green] "
|
|
82
|
+
f"Found {skill_count} {self._pluralize('Skill', skill_count)} "
|
|
83
|
+
f"for {agent_count} {self._pluralize('agent', agent_count)}"
|
|
84
|
+
)
|
|
85
|
+
return
|
|
86
|
+
if progress.stage == "downloading_bundle":
|
|
87
|
+
self._start("Downloading and verifying Skill bundle…")
|
|
88
|
+
return
|
|
89
|
+
if progress.stage == "bundle_verified":
|
|
90
|
+
self._stop()
|
|
91
|
+
console.print("[green]✓[/green] Downloaded and verified Skill bundle")
|
|
92
|
+
return
|
|
93
|
+
if progress.stage == "reconciling_target" and progress.target is not None:
|
|
94
|
+
agents = escape_markup(self._format_agents(progress.target))
|
|
95
|
+
self._start(f"Syncing {agents}…")
|
|
96
|
+
return
|
|
97
|
+
if progress.stage == "target_complete" and progress.outcome is not None:
|
|
98
|
+
self._stop()
|
|
99
|
+
self._print_target_outcome(progress.outcome)
|
|
100
|
+
return
|
|
101
|
+
if progress.stage == "already_current":
|
|
102
|
+
self._stop()
|
|
103
|
+
self._already_current = True
|
|
104
|
+
skill_count = progress.skill_count or 0
|
|
105
|
+
agent_count = progress.agent_count or 0
|
|
106
|
+
console.print(
|
|
107
|
+
"[green]✓[/green] "
|
|
108
|
+
f"All {skill_count} {self._pluralize('Skill', skill_count)} are already current "
|
|
109
|
+
f"across {agent_count} {self._pluralize('agent', agent_count)}"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
def print_enrollment(
|
|
113
|
+
self,
|
|
114
|
+
*,
|
|
115
|
+
plan: CliSyncPlanResponse,
|
|
116
|
+
targets: tuple[PhysicalTarget, ...],
|
|
117
|
+
) -> None:
|
|
118
|
+
table = Table(show_lines=False)
|
|
119
|
+
table.add_column("Skills path", style="dim")
|
|
120
|
+
table.add_column("Coding agents")
|
|
121
|
+
|
|
122
|
+
console.print()
|
|
123
|
+
console.print(
|
|
124
|
+
f"Enrolled in [cyan]{escape_markup(plan.organization.organization_name)}[/cyan]"
|
|
125
|
+
)
|
|
126
|
+
if not targets:
|
|
127
|
+
table.add_row(
|
|
128
|
+
Text("—"),
|
|
129
|
+
Text("No coding agents found"),
|
|
130
|
+
)
|
|
131
|
+
console.print(table)
|
|
132
|
+
return
|
|
133
|
+
|
|
134
|
+
for target in targets:
|
|
135
|
+
table.add_row(
|
|
136
|
+
Text(str(target.skills_root)),
|
|
137
|
+
Text(self._format_agents(target)),
|
|
138
|
+
)
|
|
139
|
+
console.print(table)
|
|
140
|
+
|
|
141
|
+
def print_background_status(self, status: str) -> None:
|
|
142
|
+
console.print(f"[green]✓[/green] Background sync {status} [dim](every 4 hours)[/dim]")
|
|
143
|
+
|
|
144
|
+
def print_result(self, execution: SyncExecution, *, verbose: bool) -> None:
|
|
145
|
+
if execution.status == "blocked":
|
|
146
|
+
reason = execution.result.get("blocked_reason") or "unknown"
|
|
147
|
+
console.print(
|
|
148
|
+
f"\nSync blocked: {reason}. Installed Skills were preserved.",
|
|
149
|
+
markup=False,
|
|
150
|
+
highlight=False,
|
|
151
|
+
)
|
|
152
|
+
self._print_changes(execution, verbose=verbose)
|
|
153
|
+
return
|
|
154
|
+
|
|
155
|
+
skill_counts = self._unique_skill_counts(execution)
|
|
156
|
+
failed_count = self._failed_count_for_summary(execution, skill_counts)
|
|
157
|
+
action_parts = [
|
|
158
|
+
label
|
|
159
|
+
for count, label in (
|
|
160
|
+
(skill_counts.get("installed", 0), f"{skill_counts.get('installed', 0)} installed"),
|
|
161
|
+
(skill_counts.get("adopted", 0), f"{skill_counts.get('adopted', 0)} adopted"),
|
|
162
|
+
(skill_counts.get("overwritten", 0), f"{skill_counts.get('overwritten', 0)} replaced"),
|
|
163
|
+
(skill_counts.get("updated", 0), f"{skill_counts.get('updated', 0)} updated"),
|
|
164
|
+
(skill_counts.get("removed", 0), f"{skill_counts.get('removed', 0)} removed"),
|
|
165
|
+
)
|
|
166
|
+
if count
|
|
167
|
+
]
|
|
168
|
+
agent_count = self._detected_agent_count(execution)
|
|
169
|
+
agents = f"{agent_count} {self._pluralize('agent', agent_count)}"
|
|
170
|
+
|
|
171
|
+
if not action_parts and failed_count == 0:
|
|
172
|
+
# Fast-path already printed "already current"; avoid a redundant summary.
|
|
173
|
+
if not self._already_current:
|
|
174
|
+
console.print(f"\nSync complete: already up to date across {agents}.")
|
|
175
|
+
else:
|
|
176
|
+
status = "complete" if execution.status == "ok" else execution.status
|
|
177
|
+
parts = [*action_parts, f"{failed_count} failed"]
|
|
178
|
+
console.print(f"\nSync {status}: {', '.join(parts)} across {agents}.")
|
|
179
|
+
|
|
180
|
+
self._print_changes(execution, verbose=verbose)
|
|
181
|
+
|
|
182
|
+
def _start(self, message: str) -> None:
|
|
183
|
+
if self._status is None:
|
|
184
|
+
self._status = Status(
|
|
185
|
+
message,
|
|
186
|
+
console=console,
|
|
187
|
+
spinner="dots",
|
|
188
|
+
)
|
|
189
|
+
self._status.start()
|
|
190
|
+
else:
|
|
191
|
+
self._status.update(message)
|
|
192
|
+
|
|
193
|
+
def _stop(self) -> None:
|
|
194
|
+
status = self._status
|
|
195
|
+
self._status = None
|
|
196
|
+
if status is None:
|
|
197
|
+
return
|
|
198
|
+
try:
|
|
199
|
+
status.stop()
|
|
200
|
+
except Exception:
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
def _unique_skill_counts(self, execution: SyncExecution) -> dict[str, int]:
|
|
204
|
+
"""Count each Skill once, even when the same change is written for multiple agents."""
|
|
205
|
+
by_skill: dict[str, set[str]] = {}
|
|
206
|
+
changes = execution.result.get("changes")
|
|
207
|
+
if isinstance(changes, list):
|
|
208
|
+
for change in changes:
|
|
209
|
+
if not isinstance(change, dict):
|
|
210
|
+
continue
|
|
211
|
+
action = change.get("action")
|
|
212
|
+
if not isinstance(action, str) or action == "unchanged":
|
|
213
|
+
continue
|
|
214
|
+
if action in ("overwritten_collision", "overwritten_local_edit"):
|
|
215
|
+
bucket = "overwritten"
|
|
216
|
+
else:
|
|
217
|
+
bucket = action
|
|
218
|
+
skill_key = change.get("skill_id") or change.get("name")
|
|
219
|
+
if not isinstance(skill_key, str) or not skill_key:
|
|
220
|
+
skill_key = str(change.get("physical_path") or id(change))
|
|
221
|
+
by_skill.setdefault(skill_key, set()).add(bucket)
|
|
222
|
+
|
|
223
|
+
counts = Counter(bucket for actions in by_skill.values() for bucket in actions)
|
|
224
|
+
return dict(counts)
|
|
225
|
+
|
|
226
|
+
def _detected_agent_count(self, execution: SyncExecution) -> int:
|
|
227
|
+
detected = execution.result.get("detected_targets")
|
|
228
|
+
if isinstance(detected, list):
|
|
229
|
+
return len(detected)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
def _failed_count_for_summary(
|
|
233
|
+
self,
|
|
234
|
+
execution: SyncExecution,
|
|
235
|
+
skill_counts: dict[str, int],
|
|
236
|
+
) -> int:
|
|
237
|
+
"""Prefer unique Skill failures; raise to target-level when partial under-reports."""
|
|
238
|
+
change_failed = skill_counts.get("failed", 0)
|
|
239
|
+
if execution.status != "partial":
|
|
240
|
+
return change_failed
|
|
241
|
+
physical_targets = execution.result.get("physical_targets")
|
|
242
|
+
if not isinstance(physical_targets, list):
|
|
243
|
+
return change_failed
|
|
244
|
+
target_failed = sum(
|
|
245
|
+
1 for target in physical_targets if isinstance(target, dict) and target.get("error") is not None
|
|
246
|
+
)
|
|
247
|
+
return max(change_failed, target_failed)
|
|
248
|
+
|
|
249
|
+
def _print_target_outcome(self, outcome: TargetOutcome) -> None:
|
|
250
|
+
agents = self._format_agents(outcome.target)
|
|
251
|
+
if outcome.error is not None:
|
|
252
|
+
error_console.print(f"✗ {agents} — {outcome.error}", markup=False, highlight=False)
|
|
253
|
+
return
|
|
254
|
+
|
|
255
|
+
counts = Counter(change.action for change in outcome.changes)
|
|
256
|
+
replaced = counts["overwritten_collision"] + counts["overwritten_local_edit"]
|
|
257
|
+
parts = [
|
|
258
|
+
label
|
|
259
|
+
for count, label in (
|
|
260
|
+
(counts["installed"], f"{counts['installed']} installed"),
|
|
261
|
+
(counts["adopted"], f"{counts['adopted']} adopted"),
|
|
262
|
+
(replaced, f"{replaced} replaced"),
|
|
263
|
+
(counts["updated"], f"{counts['updated']} updated"),
|
|
264
|
+
(counts["removed"], f"{counts['removed']} removed"),
|
|
265
|
+
(counts["failed"], f"{counts['failed']} failed"),
|
|
266
|
+
)
|
|
267
|
+
if count
|
|
268
|
+
]
|
|
269
|
+
detail = ", ".join(parts) if parts else "up to date"
|
|
270
|
+
# One line per physical write; list every agent that shares that path.
|
|
271
|
+
console.print(f"[green]✓[/green] {escape_markup(agents)} — {detail}")
|
|
272
|
+
|
|
273
|
+
def _print_changes(self, execution: SyncExecution, *, verbose: bool) -> None:
|
|
274
|
+
changes = execution.result.get("changes")
|
|
275
|
+
if not isinstance(changes, list):
|
|
276
|
+
return
|
|
277
|
+
|
|
278
|
+
visible = [
|
|
279
|
+
change
|
|
280
|
+
for change in changes
|
|
281
|
+
if isinstance(change, dict)
|
|
282
|
+
and self._change_is_visible(change, verbose=verbose)
|
|
283
|
+
]
|
|
284
|
+
if not visible:
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
console.print("\nChanges:")
|
|
288
|
+
for change in visible:
|
|
289
|
+
action = str(change.get("action", "changed"))
|
|
290
|
+
label = _ACTION_LABELS.get(action, action.replace("_", " "))
|
|
291
|
+
agents = self._format_agent_names(change.get("targets"))
|
|
292
|
+
line = f" {label}: {change.get('name')}"
|
|
293
|
+
if agents:
|
|
294
|
+
line += f" ({agents})"
|
|
295
|
+
if change.get("backup_path"):
|
|
296
|
+
line += f" backup: {change['backup_path']}"
|
|
297
|
+
if change.get("error"):
|
|
298
|
+
line += f" error: {change['error']}"
|
|
299
|
+
error_console.print(line, markup=False, highlight=False)
|
|
300
|
+
else:
|
|
301
|
+
console.print(line, markup=False, highlight=False)
|
|
302
|
+
|
|
303
|
+
def _change_is_visible(self, change: dict[str, object], *, verbose: bool) -> bool:
|
|
304
|
+
action = change.get("action")
|
|
305
|
+
if change.get("error"):
|
|
306
|
+
return True
|
|
307
|
+
if action == "unchanged":
|
|
308
|
+
return False
|
|
309
|
+
if verbose:
|
|
310
|
+
return True
|
|
311
|
+
if change.get("backup_path"):
|
|
312
|
+
return True
|
|
313
|
+
return action in _CRITICAL_ACTIONS
|
|
314
|
+
|
|
315
|
+
def _format_agents(self, target: PhysicalTarget) -> str:
|
|
316
|
+
return self._format_agent_names(target.logical_targets)
|
|
317
|
+
|
|
318
|
+
def _format_agent_names(self, names: object) -> str:
|
|
319
|
+
if not isinstance(names, (list, tuple)):
|
|
320
|
+
return ""
|
|
321
|
+
labels = [self._agent_label(str(name)) for name in names]
|
|
322
|
+
return ", ".join(labels)
|
|
323
|
+
|
|
324
|
+
def _agent_label(self, name: str) -> str:
|
|
325
|
+
return _AGENT_LABELS.get(name, name.replace("_", "-"))
|
|
326
|
+
|
|
327
|
+
def _pluralize(self, word: str, count: int) -> str:
|
|
328
|
+
return word if count == 1 else f"{word}s"
|