forgexa-cli 1.26.0__tar.gz → 1.26.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.
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/PKG-INFO +1 -1
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/daemon.py +12 -4
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/main.py +14 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/pyproject.toml +1 -1
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_local_bind_commands.py +58 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/README.md +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/setup.cfg +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.26.0 → forgexa_cli-1.26.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.26.
|
|
2
|
+
__version__ = "1.26.2"
|
|
@@ -909,7 +909,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
909
909
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
910
910
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
911
911
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
912
|
-
DAEMON_VERSION = "1.26.
|
|
912
|
+
DAEMON_VERSION = "1.26.2"
|
|
913
913
|
|
|
914
914
|
|
|
915
915
|
def _detect_client_type() -> str:
|
|
@@ -971,6 +971,14 @@ logger = logging.getLogger("daemon")
|
|
|
971
971
|
logger.info("Daemon log: %s", DAEMON_LOG_PATH)
|
|
972
972
|
|
|
973
973
|
|
|
974
|
+
def _format_exc(exc: BaseException) -> str:
|
|
975
|
+
"""Render an exception for logging, always including its type name so a
|
|
976
|
+
str()-less exception (e.g. an empty httpx/asyncio timeout) never logs as
|
|
977
|
+
a blank, undiagnosable "... error: " line."""
|
|
978
|
+
detail = str(exc)
|
|
979
|
+
return f"{type(exc).__name__}: {detail}" if detail else type(exc).__name__
|
|
980
|
+
|
|
981
|
+
|
|
974
982
|
# ── Hardware ID — stable cross-IP machine fingerprint ──
|
|
975
983
|
|
|
976
984
|
|
|
@@ -6776,7 +6784,7 @@ class HeartbeatService:
|
|
|
6776
6784
|
else:
|
|
6777
6785
|
logger.warning("Heartbeat failed: %s %s", resp.status_code, resp.text)
|
|
6778
6786
|
except Exception as e:
|
|
6779
|
-
logger.warning("Heartbeat error: %s", e)
|
|
6787
|
+
logger.warning("Heartbeat error: %s", _format_exc(e))
|
|
6780
6788
|
|
|
6781
6789
|
await asyncio.sleep(self.interval)
|
|
6782
6790
|
|
|
@@ -6940,7 +6948,7 @@ class TaskPoller:
|
|
|
6940
6948
|
))
|
|
6941
6949
|
return tasks
|
|
6942
6950
|
except Exception as e:
|
|
6943
|
-
logger.warning("Task poll error: %s", e)
|
|
6951
|
+
logger.warning("Task poll error: %s", _format_exc(e))
|
|
6944
6952
|
return []
|
|
6945
6953
|
|
|
6946
6954
|
async def poll_ai_jobs(self) -> list[dict]:
|
|
@@ -6964,7 +6972,7 @@ class TaskPoller:
|
|
|
6964
6972
|
self.cancelled_ai_job_ids = []
|
|
6965
6973
|
return []
|
|
6966
6974
|
except Exception as e:
|
|
6967
|
-
logger.debug("AIJob poll error: %s", e)
|
|
6975
|
+
logger.debug("AIJob poll error: %s", _format_exc(e))
|
|
6968
6976
|
self.cancelled_ai_job_ids = []
|
|
6969
6977
|
return []
|
|
6970
6978
|
|
|
@@ -2859,6 +2859,20 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
2859
2859
|
sys.exit(1)
|
|
2860
2860
|
|
|
2861
2861
|
mapping = load_local_workspaces_mapping()
|
|
2862
|
+
local_path_str = str(local_path)
|
|
2863
|
+
# A folder can only ever be validly bound to one binding_id at a time -
|
|
2864
|
+
# drop any other (necessarily stale) entry already pointing at this same
|
|
2865
|
+
# path first. Without this, a stale entry left behind by an earlier
|
|
2866
|
+
# attempt (e.g. a re-registered runtime after a reinstall, or a
|
|
2867
|
+
# verification that timed out client-side) can linger alongside the new
|
|
2868
|
+
# one, and agent_host.py's `_match_local_binding_id` (a plain path scan
|
|
2869
|
+
# with no tie-breaking) can resolve to the wrong, orphaned binding_id
|
|
2870
|
+
# instead of the one just verified.
|
|
2871
|
+
mapping = {
|
|
2872
|
+
key: entry
|
|
2873
|
+
for key, entry in mapping.items()
|
|
2874
|
+
if key == binding_id or entry.get("local_path") != local_path_str
|
|
2875
|
+
}
|
|
2862
2876
|
mapping[binding_id] = {
|
|
2863
2877
|
"project_id": args.project,
|
|
2864
2878
|
"user_id": user_id,
|
|
@@ -70,6 +70,64 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
74
|
+
"""A stale entry from an earlier attempt (e.g. before a daemon/runtime
|
|
75
|
+
reinstall) must not linger alongside a freshly-verified one for the same
|
|
76
|
+
folder - agent_host.py's _match_local_binding_id does a plain path scan
|
|
77
|
+
with no tie-breaking, so a leftover duplicate can resolve to the wrong,
|
|
78
|
+
orphaned binding_id and fail turn.start with binding_verification_required
|
|
79
|
+
even though the IDE just linked successfully."""
|
|
80
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
81
|
+
repo = tmp_path / "my-real-project"
|
|
82
|
+
_init_git_repo_with_remote(repo)
|
|
83
|
+
|
|
84
|
+
mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
|
|
85
|
+
mapping_path.parent.mkdir(parents=True, exist_ok=True)
|
|
86
|
+
mapping_path.write_text(json.dumps({
|
|
87
|
+
"binding-old": {
|
|
88
|
+
"project_id": "project-1",
|
|
89
|
+
"user_id": "user-1",
|
|
90
|
+
"runtime_id": "runtime-old",
|
|
91
|
+
"repo_identity": "github.com/acme/widgets",
|
|
92
|
+
"local_path": str(repo.resolve()),
|
|
93
|
+
},
|
|
94
|
+
}))
|
|
95
|
+
|
|
96
|
+
monkeypatch.setattr(main, "_get_runtimes", lambda: [
|
|
97
|
+
{"id": "runtime-new", "pool_type": "personal", "daemon_id": "d2", "device_name": "laptop"},
|
|
98
|
+
])
|
|
99
|
+
|
|
100
|
+
def fake_get(path: str):
|
|
101
|
+
if path == "/auth/me":
|
|
102
|
+
return {"id": "user-1"}
|
|
103
|
+
assert path == "/projects/project-1/local-workspace-bindings/binding-new"
|
|
104
|
+
return {"id": "binding-new", "status": "active"}
|
|
105
|
+
|
|
106
|
+
def fake_post(path: str, data=None):
|
|
107
|
+
if path.endswith("/local-workspace-bindings"):
|
|
108
|
+
return {"id": "binding-new", "status": "pending"}
|
|
109
|
+
if path.endswith("/verify"):
|
|
110
|
+
return {"id": "binding-new", "status": "active"}
|
|
111
|
+
raise AssertionError(f"unexpected POST {path}")
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr(main, "_get", fake_get)
|
|
114
|
+
monkeypatch.setattr(main, "_post", fake_post)
|
|
115
|
+
|
|
116
|
+
args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
|
|
117
|
+
main.cmd_project_bind_local(args)
|
|
118
|
+
|
|
119
|
+
mapping = json.loads(mapping_path.read_text())
|
|
120
|
+
assert mapping == {
|
|
121
|
+
"binding-new": {
|
|
122
|
+
"project_id": "project-1",
|
|
123
|
+
"user_id": "user-1",
|
|
124
|
+
"runtime_id": "runtime-new",
|
|
125
|
+
"repo_identity": "github.com/acme/widgets",
|
|
126
|
+
"local_path": str(repo.resolve()),
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
|
|
73
131
|
def test_bind_local_rejects_non_git_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
74
132
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
75
133
|
plain_dir = tmp_path / "not-a-repo"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|