forgexa-cli 1.47.3__tar.gz → 1.47.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.
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/PKG-INFO +9 -3
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/README.md +8 -2
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/daemon.py +281 -51
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/main.py +27 -4
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/PKG-INFO +9 -3
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/pyproject.toml +1 -1
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_auth_and_runtime_commands.py +21 -2
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/setup.cfg +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.47.3 → forgexa_cli-1.47.4}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.47.
|
|
3
|
+
Version: 1.47.4
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -132,10 +132,16 @@ forgexa upgrade --target-version 1.12.3
|
|
|
132
132
|
|
|
133
133
|
The command detects how `forgexa-cli` was installed:
|
|
134
134
|
|
|
135
|
-
- `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
|
|
136
|
-
- `pip` install: uses `python -m pip install --upgrade forgexa-cli`
|
|
135
|
+
- `pipx` install: uses `pipx upgrade --index-url https://pypi.org/simple forgexa-cli`, or `pipx install --force --index-url https://pypi.org/simple forgexa-cli==<version>` for a pinned version
|
|
136
|
+
- `pip` install: uses `python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli`
|
|
137
137
|
- non-editable local path install (`pip install /path/to/cli`): follows the same `pip` upgrade path and switches to the published PyPI release
|
|
138
138
|
|
|
139
|
+
Self-upgrades pass `--index-url https://pypi.org/simple` to pip or pipx, matching the official PyPI release check and avoiding a configured mirror that has not yet synchronized a new release. A direct `pip install --upgrade forgexa-cli` command always follows that Python environment's pip configuration. When its mirror is behind, upgrade manually with:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli==1.12.3
|
|
143
|
+
```
|
|
144
|
+
|
|
139
145
|
For safety, `forgexa upgrade` stops any running local daemon before upgrading. If the daemon was previously started in background mode, the CLI starts it again automatically after a successful upgrade; otherwise it prints a manual restart hint.
|
|
140
146
|
|
|
141
147
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
@@ -100,10 +100,16 @@ forgexa upgrade --target-version 1.12.3
|
|
|
100
100
|
|
|
101
101
|
The command detects how `forgexa-cli` was installed:
|
|
102
102
|
|
|
103
|
-
- `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
|
|
104
|
-
- `pip` install: uses `python -m pip install --upgrade forgexa-cli`
|
|
103
|
+
- `pipx` install: uses `pipx upgrade --index-url https://pypi.org/simple forgexa-cli`, or `pipx install --force --index-url https://pypi.org/simple forgexa-cli==<version>` for a pinned version
|
|
104
|
+
- `pip` install: uses `python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli`
|
|
105
105
|
- non-editable local path install (`pip install /path/to/cli`): follows the same `pip` upgrade path and switches to the published PyPI release
|
|
106
106
|
|
|
107
|
+
Self-upgrades pass `--index-url https://pypi.org/simple` to pip or pipx, matching the official PyPI release check and avoiding a configured mirror that has not yet synchronized a new release. A direct `pip install --upgrade forgexa-cli` command always follows that Python environment's pip configuration. When its mirror is behind, upgrade manually with:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli==1.12.3
|
|
111
|
+
```
|
|
112
|
+
|
|
107
113
|
For safety, `forgexa upgrade` stops any running local daemon before upgrading. If the daemon was previously started in background mode, the CLI starts it again automatically after a successful upgrade; otherwise it prints a manual restart hint.
|
|
108
114
|
|
|
109
115
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.47.
|
|
2
|
+
__version__ = "1.47.4"
|
|
@@ -197,6 +197,7 @@ def _windows_daemon_process_query(exclude_pid: int) -> str:
|
|
|
197
197
|
|
|
198
198
|
|
|
199
199
|
_LOCAL_RUNTIME_REGISTRATION_SCHEMA_VERSION = 1
|
|
200
|
+
_DESKTOP_DAEMON_RESTART_REQUEST_SCHEMA_VERSION = 1
|
|
200
201
|
|
|
201
202
|
|
|
202
203
|
def _local_runtime_registration_path() -> Path:
|
|
@@ -210,6 +211,47 @@ def _local_runtime_registration_path() -> Path:
|
|
|
210
211
|
return Path.home() / ".forgexa" / "daemon" / "runtime-registrations.json"
|
|
211
212
|
|
|
212
213
|
|
|
214
|
+
def _desktop_daemon_restart_request_path(instance_id: str) -> Path:
|
|
215
|
+
return (
|
|
216
|
+
Path.home()
|
|
217
|
+
/ ".forgexa"
|
|
218
|
+
/ "daemon"
|
|
219
|
+
/ "restart-requests"
|
|
220
|
+
/ f"{instance_id}.json"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _desktop_daemon_restart_target_version(instance_id: str) -> str | None:
|
|
225
|
+
"""Return a restart target only when the request belongs to this daemon."""
|
|
226
|
+
try:
|
|
227
|
+
request = json.loads(
|
|
228
|
+
_desktop_daemon_restart_request_path(instance_id).read_text(encoding="utf-8")
|
|
229
|
+
)
|
|
230
|
+
except (OSError, ValueError):
|
|
231
|
+
return None
|
|
232
|
+
if not isinstance(request, dict):
|
|
233
|
+
return None
|
|
234
|
+
if request.get("schema_version") != _DESKTOP_DAEMON_RESTART_REQUEST_SCHEMA_VERSION:
|
|
235
|
+
return None
|
|
236
|
+
if request.get("instance_id") != instance_id:
|
|
237
|
+
return None
|
|
238
|
+
target_version = request.get("target_version")
|
|
239
|
+
return target_version.strip() if isinstance(target_version, str) and target_version.strip() else None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _clear_desktop_daemon_restart_request(instance_id: str) -> None:
|
|
243
|
+
path = _desktop_daemon_restart_request_path(instance_id)
|
|
244
|
+
try:
|
|
245
|
+
request = json.loads(path.read_text(encoding="utf-8"))
|
|
246
|
+
if not isinstance(request, dict) or request.get("instance_id") != instance_id:
|
|
247
|
+
return
|
|
248
|
+
path.unlink(missing_ok=True)
|
|
249
|
+
except FileNotFoundError:
|
|
250
|
+
return
|
|
251
|
+
except (OSError, ValueError):
|
|
252
|
+
logger.warning("Failed to clear desktop daemon restart request")
|
|
253
|
+
|
|
254
|
+
|
|
213
255
|
def _write_local_runtime_registration_snapshot(
|
|
214
256
|
instance_id: str,
|
|
215
257
|
state: str,
|
|
@@ -221,13 +263,16 @@ def _write_local_runtime_registration_snapshot(
|
|
|
221
263
|
"schema_version": _LOCAL_RUNTIME_REGISTRATION_SCHEMA_VERSION,
|
|
222
264
|
"pid": os.getpid(),
|
|
223
265
|
"instance_id": instance_id,
|
|
266
|
+
"daemon_version": DAEMON_VERSION,
|
|
267
|
+
"client_type": _CLIENT_TYPE,
|
|
268
|
+
"desktop_restart_protocol_version": _DESKTOP_DAEMON_RESTART_REQUEST_SCHEMA_VERSION,
|
|
224
269
|
"updated_at": int(time.time()),
|
|
225
270
|
"state": state,
|
|
226
271
|
"registrations": registrations,
|
|
227
272
|
}
|
|
228
273
|
try:
|
|
229
274
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
-
tmp_path = path.with_name(path.name
|
|
275
|
+
tmp_path = path.with_name(f"{path.name}.{os.getpid()}.tmp")
|
|
231
276
|
tmp_path.write_text(json.dumps(payload, indent=2, sort_keys=True), encoding="utf-8")
|
|
232
277
|
if os.name != "nt":
|
|
233
278
|
os.chmod(tmp_path, 0o600)
|
|
@@ -1075,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1075
1120
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1076
1121
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1077
1122
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1078
|
-
DAEMON_VERSION = "1.47.
|
|
1123
|
+
DAEMON_VERSION = "1.47.4"
|
|
1079
1124
|
|
|
1080
1125
|
|
|
1081
1126
|
def _detect_client_type() -> str:
|
|
@@ -1508,14 +1553,19 @@ def _filter_local_bind_task_result(result: "TaskResult") -> None:
|
|
|
1508
1553
|
agent observations are classified as data that must NEVER leave the
|
|
1509
1554
|
user's machine by default for workspace_resolution="local_bind" tasks —
|
|
1510
1555
|
only Git-tracked artifacts (already pushed) and status/metrics/Git
|
|
1511
|
-
metadata sync.
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
right before ProgressReporter.report_complete().
|
|
1556
|
+
metadata sync. Failure codes, metrics, Git metadata, artifacts, and
|
|
1557
|
+
evidence summaries are preserved; free-text errors are replaced because
|
|
1558
|
+
they can contain local paths or Agent output. Mutates `result` in place;
|
|
1559
|
+
call this right before ProgressReporter.report_complete().
|
|
1515
1560
|
"""
|
|
1516
1561
|
result.stdout = ""
|
|
1517
1562
|
result.stderr = ""
|
|
1518
1563
|
result.observations = []
|
|
1564
|
+
if result.error:
|
|
1565
|
+
result.error = (
|
|
1566
|
+
"Local task failed. Review this task in Forgexa Desktop on the "
|
|
1567
|
+
"running device."
|
|
1568
|
+
)
|
|
1519
1569
|
|
|
1520
1570
|
def _prepare_ai_job_output(
|
|
1521
1571
|
*,
|
|
@@ -2212,6 +2262,99 @@ def _append_local_bind_output_log(task_id: str, lines: list[str]) -> None:
|
|
|
2212
2262
|
logger.warning("Failed to append local-bind output log for task %s: %s", task_id, e)
|
|
2213
2263
|
|
|
2214
2264
|
|
|
2265
|
+
def _local_bind_tool_activity(tool_name: object, completed: bool = False) -> str:
|
|
2266
|
+
"""Return a content-free activity label for a local Agent tool event."""
|
|
2267
|
+
normalized = tool_name.lower() if isinstance(tool_name, str) else ""
|
|
2268
|
+
if normalized in {
|
|
2269
|
+
"read", "read_file", "file_read", "file_search", "grep_search",
|
|
2270
|
+
"semantic_search", "search", "list_dir", "glob",
|
|
2271
|
+
}:
|
|
2272
|
+
return "agent.inspecting_files"
|
|
2273
|
+
if normalized in {
|
|
2274
|
+
"write", "create_file", "apply_patch", "str_replace", "str_replace_editor",
|
|
2275
|
+
"file_edit", "file_change", "edit",
|
|
2276
|
+
}:
|
|
2277
|
+
return "agent.updating_files"
|
|
2278
|
+
if normalized in {
|
|
2279
|
+
"bash", "run_in_terminal", "execute_command", "command_execution", "terminal",
|
|
2280
|
+
}:
|
|
2281
|
+
return "agent.completed_command" if completed else "agent.running_command"
|
|
2282
|
+
if normalized in {"get_errors", "lint", "test"}:
|
|
2283
|
+
return "agent.checking_diagnostics"
|
|
2284
|
+
return "agent.using_workspace_tool"
|
|
2285
|
+
|
|
2286
|
+
|
|
2287
|
+
def _summarize_local_bind_output(lines: list[str]) -> list[str]:
|
|
2288
|
+
"""Derive Web-safe activity labels without returning Agent output content."""
|
|
2289
|
+
activity_lines: list[str] = []
|
|
2290
|
+
seen: set[str] = set()
|
|
2291
|
+
|
|
2292
|
+
for line in lines:
|
|
2293
|
+
stripped = line.strip()
|
|
2294
|
+
if not stripped:
|
|
2295
|
+
continue
|
|
2296
|
+
try:
|
|
2297
|
+
event = json.loads(stripped)
|
|
2298
|
+
except json.JSONDecodeError:
|
|
2299
|
+
activity = "agent.producing_output"
|
|
2300
|
+
else:
|
|
2301
|
+
if not isinstance(event, dict):
|
|
2302
|
+
continue
|
|
2303
|
+
event_type = event.get("type")
|
|
2304
|
+
data = event.get("data") if isinstance(event.get("data"), dict) else event
|
|
2305
|
+
activity = None
|
|
2306
|
+
|
|
2307
|
+
if event_type == "tool.execution_start":
|
|
2308
|
+
activity = _local_bind_tool_activity(data.get("toolName"))
|
|
2309
|
+
elif event_type == "assistant.message":
|
|
2310
|
+
activity = (
|
|
2311
|
+
"agent.planning_next_action"
|
|
2312
|
+
if data.get("toolRequests")
|
|
2313
|
+
else "agent.wrote_update"
|
|
2314
|
+
)
|
|
2315
|
+
elif event_type == "assistant":
|
|
2316
|
+
message = event.get("message")
|
|
2317
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
2318
|
+
if isinstance(content, list):
|
|
2319
|
+
for item in content:
|
|
2320
|
+
if not isinstance(item, dict):
|
|
2321
|
+
continue
|
|
2322
|
+
if item.get("type") == "tool_use":
|
|
2323
|
+
activity = _local_bind_tool_activity(item.get("name"))
|
|
2324
|
+
break
|
|
2325
|
+
if item.get("type") == "text":
|
|
2326
|
+
activity = "agent.wrote_update"
|
|
2327
|
+
break
|
|
2328
|
+
elif event_type in {"item.started", "item.completed"}:
|
|
2329
|
+
item = event.get("item")
|
|
2330
|
+
item_type = item.get("type") if isinstance(item, dict) else None
|
|
2331
|
+
if item_type == "agent_message":
|
|
2332
|
+
activity = "agent.wrote_update"
|
|
2333
|
+
elif item_type:
|
|
2334
|
+
activity = _local_bind_tool_activity(
|
|
2335
|
+
item_type,
|
|
2336
|
+
completed=event_type == "item.completed",
|
|
2337
|
+
)
|
|
2338
|
+
elif event_type == "tool_use":
|
|
2339
|
+
part = event.get("part")
|
|
2340
|
+
tool_name = part.get("tool") if isinstance(part, dict) else None
|
|
2341
|
+
activity = _local_bind_tool_activity(tool_name)
|
|
2342
|
+
elif event_type == "text":
|
|
2343
|
+
activity = "agent.wrote_update"
|
|
2344
|
+
elif event_type == "result":
|
|
2345
|
+
activity = (
|
|
2346
|
+
"agent.reported_error"
|
|
2347
|
+
if event.get("is_error")
|
|
2348
|
+
else "agent.completed_response"
|
|
2349
|
+
)
|
|
2350
|
+
|
|
2351
|
+
if activity and activity not in seen:
|
|
2352
|
+
activity_lines.append(activity)
|
|
2353
|
+
seen.add(activity)
|
|
2354
|
+
|
|
2355
|
+
return activity_lines
|
|
2356
|
+
|
|
2357
|
+
|
|
2215
2358
|
_LOCAL_BIND_SSH_SCHEME_RE = re.compile(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
|
|
2216
2359
|
_LOCAL_BIND_HTTP_SCHEME_RE = re.compile(r"^https?://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
|
|
2217
2360
|
_LOCAL_BIND_SCP_LIKE_RE = re.compile(r"^[^@/]+@([^:/]+):(.+?)(?:\.git)?/?$")
|
|
@@ -5382,6 +5525,7 @@ class ProcessManager:
|
|
|
5382
5525
|
timeout,
|
|
5383
5526
|
task.task_id,
|
|
5384
5527
|
on_chunk,
|
|
5528
|
+
stream_stderr=task.workspace_resolution == "local_bind",
|
|
5385
5529
|
disable_builtin_mcps=task.input_data.get("task_type") in {
|
|
5386
5530
|
"test_script_generate",
|
|
5387
5531
|
"qa_scenario_preview",
|
|
@@ -6274,6 +6418,7 @@ class ProcessManager:
|
|
|
6274
6418
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
6275
6419
|
on_chunk: Any = None,
|
|
6276
6420
|
*,
|
|
6421
|
+
stream_stderr: bool = False,
|
|
6277
6422
|
disable_builtin_mcps: bool = False,
|
|
6278
6423
|
_retry_without_effort: bool = False,
|
|
6279
6424
|
_retry_without_token_env: bool = False,
|
|
@@ -6454,6 +6599,7 @@ class ProcessManager:
|
|
|
6454
6599
|
stdout, stderr, returncode = await self._stream_process(
|
|
6455
6600
|
proc, None, timeout, task_id, on_chunk,
|
|
6456
6601
|
workspace_path=cwd,
|
|
6602
|
+
stream_stderr=stream_stderr,
|
|
6457
6603
|
)
|
|
6458
6604
|
|
|
6459
6605
|
# Parse copilot JSONL output for metrics
|
|
@@ -6515,6 +6661,7 @@ class ProcessManager:
|
|
|
6515
6661
|
timeout,
|
|
6516
6662
|
task_id,
|
|
6517
6663
|
on_chunk,
|
|
6664
|
+
stream_stderr=stream_stderr,
|
|
6518
6665
|
disable_builtin_mcps=True,
|
|
6519
6666
|
_retry_without_effort=_retry_without_effort,
|
|
6520
6667
|
_retry_without_token_env=_retry_without_token_env,
|
|
@@ -6559,6 +6706,7 @@ class ProcessManager:
|
|
|
6559
6706
|
timeout,
|
|
6560
6707
|
task_id,
|
|
6561
6708
|
on_chunk,
|
|
6709
|
+
stream_stderr=stream_stderr,
|
|
6562
6710
|
disable_builtin_mcps=disable_builtin_mcps,
|
|
6563
6711
|
_retry_without_effort=_retry_without_effort,
|
|
6564
6712
|
_retry_without_token_env=True,
|
|
@@ -6622,6 +6770,7 @@ class ProcessManager:
|
|
|
6622
6770
|
)
|
|
6623
6771
|
return await self._run_copilot(
|
|
6624
6772
|
agent, prompt, cwd, timeout, task_id, on_chunk,
|
|
6773
|
+
stream_stderr=stream_stderr,
|
|
6625
6774
|
disable_builtin_mcps=disable_builtin_mcps,
|
|
6626
6775
|
_retry_without_effort=True,
|
|
6627
6776
|
_retry_without_token_env=_retry_without_token_env,
|
|
@@ -7056,6 +7205,7 @@ class ProgressReporter:
|
|
|
7056
7205
|
self.server_url = server_url.rstrip("/")
|
|
7057
7206
|
self.runtime_id = runtime_id
|
|
7058
7207
|
self._last_progress_sent: dict[str, dict[str, Any]] = {}
|
|
7208
|
+
self._pending_output_lines: dict[str, list[str]] = {}
|
|
7059
7209
|
|
|
7060
7210
|
def _should_skip_progress(
|
|
7061
7211
|
self,
|
|
@@ -7063,11 +7213,12 @@ class ProgressReporter:
|
|
|
7063
7213
|
progress_percent: int,
|
|
7064
7214
|
current_step: str,
|
|
7065
7215
|
output_lines: list[str],
|
|
7216
|
+
activity_lines: list[str],
|
|
7066
7217
|
files_changed: list[str],
|
|
7067
7218
|
lines_added: int,
|
|
7068
7219
|
lines_removed: int,
|
|
7069
7220
|
) -> bool:
|
|
7070
|
-
if output_lines or files_changed or lines_added or lines_removed:
|
|
7221
|
+
if output_lines or activity_lines or files_changed or lines_added or lines_removed:
|
|
7071
7222
|
return False
|
|
7072
7223
|
|
|
7073
7224
|
last = self._last_progress_sent.get(task_id)
|
|
@@ -7085,47 +7236,68 @@ class ProgressReporter:
|
|
|
7085
7236
|
progress_percent: int,
|
|
7086
7237
|
current_step: str,
|
|
7087
7238
|
output_lines: list[str] | None = None,
|
|
7239
|
+
activity_lines: list[str] | None = None,
|
|
7088
7240
|
files_changed: list[str] | None = None,
|
|
7089
7241
|
lines_added: int = 0,
|
|
7090
7242
|
lines_removed: int = 0,
|
|
7091
|
-
):
|
|
7092
|
-
output_lines =
|
|
7243
|
+
) -> bool:
|
|
7244
|
+
output_lines = [
|
|
7245
|
+
*self._pending_output_lines.get(task_id, []),
|
|
7246
|
+
*(output_lines or []),
|
|
7247
|
+
]
|
|
7248
|
+
activity_lines = activity_lines or []
|
|
7093
7249
|
files_changed = files_changed or []
|
|
7094
7250
|
if self._should_skip_progress(
|
|
7095
7251
|
task_id,
|
|
7096
7252
|
progress_percent,
|
|
7097
7253
|
current_step,
|
|
7098
7254
|
output_lines,
|
|
7255
|
+
activity_lines,
|
|
7099
7256
|
files_changed,
|
|
7100
7257
|
lines_added,
|
|
7101
7258
|
lines_removed,
|
|
7102
7259
|
):
|
|
7103
|
-
return
|
|
7260
|
+
return True
|
|
7104
7261
|
|
|
7105
7262
|
try:
|
|
7106
|
-
await self.client.post(
|
|
7263
|
+
response = await self.client.post(
|
|
7107
7264
|
f"{self.server_url}/api/v1/runtimes/{self.runtime_id}/tasks/{task_id}/progress",
|
|
7108
7265
|
json={
|
|
7109
7266
|
"progress_percent": progress_percent,
|
|
7110
7267
|
"current_step": current_step,
|
|
7111
7268
|
"output_lines": output_lines,
|
|
7269
|
+
"activity_lines": activity_lines,
|
|
7112
7270
|
"files_changed": files_changed,
|
|
7113
7271
|
"lines_added": lines_added,
|
|
7114
7272
|
"lines_removed": lines_removed,
|
|
7115
7273
|
},
|
|
7116
7274
|
timeout=10,
|
|
7117
7275
|
)
|
|
7118
|
-
|
|
7119
|
-
|
|
7120
|
-
|
|
7121
|
-
|
|
7122
|
-
|
|
7276
|
+
if 200 <= response.status_code < 300:
|
|
7277
|
+
self._pending_output_lines.pop(task_id, None)
|
|
7278
|
+
self._last_progress_sent[task_id] = {
|
|
7279
|
+
"progress_percent": progress_percent,
|
|
7280
|
+
"current_step": current_step,
|
|
7281
|
+
"sent_at": time.monotonic(),
|
|
7282
|
+
}
|
|
7283
|
+
return True
|
|
7284
|
+
logger.warning(
|
|
7285
|
+
"Failed to report progress for task %s: server returned %s %s",
|
|
7286
|
+
task_id,
|
|
7287
|
+
response.status_code,
|
|
7288
|
+
response.text[:500],
|
|
7289
|
+
)
|
|
7123
7290
|
except Exception as e:
|
|
7124
|
-
logger.warning("Failed to report progress for task %s: %s", task_id, e)
|
|
7291
|
+
logger.warning("Failed to report progress for task %s: %s", task_id, _format_exc(e))
|
|
7292
|
+
|
|
7293
|
+
if output_lines:
|
|
7294
|
+
self._pending_output_lines[task_id] = output_lines[-5000:]
|
|
7295
|
+
return False
|
|
7125
7296
|
|
|
7126
7297
|
async def report_complete(self, task_id: str, result: TaskResult):
|
|
7127
7298
|
"""Report task completion with retry."""
|
|
7128
7299
|
self._last_progress_sent.pop(task_id, None)
|
|
7300
|
+
self._pending_output_lines.pop(task_id, None)
|
|
7129
7301
|
payload = {
|
|
7130
7302
|
"status": result.status,
|
|
7131
7303
|
"exit_code": result.exit_code,
|
|
@@ -7717,6 +7889,7 @@ class TaskPoller:
|
|
|
7717
7889
|
self._on_auth_failure()
|
|
7718
7890
|
return []
|
|
7719
7891
|
else:
|
|
7892
|
+
logger.warning("Task poll failed: %s %s", resp.status_code, resp.text[:500])
|
|
7720
7893
|
return []
|
|
7721
7894
|
|
|
7722
7895
|
data = resp.json()
|
|
@@ -7775,10 +7948,12 @@ class TaskPoller:
|
|
|
7775
7948
|
return data.get("ai_jobs", [])
|
|
7776
7949
|
elif resp.status_code in (401, 403):
|
|
7777
7950
|
self._on_auth_failure()
|
|
7951
|
+
else:
|
|
7952
|
+
logger.warning("AIJob poll failed: %s %s", resp.status_code, resp.text[:500])
|
|
7778
7953
|
self.cancelled_ai_job_ids = []
|
|
7779
7954
|
return []
|
|
7780
7955
|
except Exception as e:
|
|
7781
|
-
logger.
|
|
7956
|
+
logger.warning("AIJob poll error: %s", _format_exc(e))
|
|
7782
7957
|
self.cancelled_ai_job_ids = []
|
|
7783
7958
|
return []
|
|
7784
7959
|
|
|
@@ -8715,6 +8890,7 @@ class RuntimeDaemon:
|
|
|
8715
8890
|
self._shutdown = False
|
|
8716
8891
|
self._local_runtime_instance_id = secrets.token_urlsafe(18)
|
|
8717
8892
|
self._local_runtime_registrations: dict[str, dict[str, int | str]] = {}
|
|
8893
|
+
self._desktop_restart_requested = False
|
|
8718
8894
|
|
|
8719
8895
|
# Deployment merge worktree GC: runs at most every 6 hours.
|
|
8720
8896
|
self._last_merge_gc: float = 0.0
|
|
@@ -8801,10 +8977,32 @@ class RuntimeDaemon:
|
|
|
8801
8977
|
}
|
|
8802
8978
|
_write_local_runtime_registration_snapshot(
|
|
8803
8979
|
self._local_runtime_instance_id,
|
|
8804
|
-
"ready",
|
|
8980
|
+
"draining" if getattr(self, "_desktop_restart_requested", False) else "ready",
|
|
8805
8981
|
self._local_runtime_registrations,
|
|
8806
8982
|
)
|
|
8807
8983
|
|
|
8984
|
+
def _begin_desktop_restart_drain_if_requested(self) -> bool:
|
|
8985
|
+
target_version = _desktop_daemon_restart_target_version(
|
|
8986
|
+
self._local_runtime_instance_id
|
|
8987
|
+
)
|
|
8988
|
+
if not target_version:
|
|
8989
|
+
return self._desktop_restart_requested
|
|
8990
|
+
if target_version == DAEMON_VERSION:
|
|
8991
|
+
_clear_desktop_daemon_restart_request(self._local_runtime_instance_id)
|
|
8992
|
+
return self._desktop_restart_requested
|
|
8993
|
+
if not self._desktop_restart_requested:
|
|
8994
|
+
self._desktop_restart_requested = True
|
|
8995
|
+
_write_local_runtime_registration_snapshot(
|
|
8996
|
+
self._local_runtime_instance_id,
|
|
8997
|
+
"draining",
|
|
8998
|
+
self._local_runtime_registrations,
|
|
8999
|
+
)
|
|
9000
|
+
logger.info(
|
|
9001
|
+
"Desktop requested daemon restart for version %s; draining active tasks",
|
|
9002
|
+
target_version,
|
|
9003
|
+
)
|
|
9004
|
+
return True
|
|
9005
|
+
|
|
8808
9006
|
@staticmethod
|
|
8809
9007
|
def _resolve_task_output_dir(task: TaskInfo) -> str:
|
|
8810
9008
|
input_data = task.input_data or {}
|
|
@@ -9270,7 +9468,16 @@ class RuntimeDaemon:
|
|
|
9270
9468
|
# 3. Main loop
|
|
9271
9469
|
try:
|
|
9272
9470
|
while not self._shutdown:
|
|
9273
|
-
|
|
9471
|
+
draining_for_desktop_restart = self._begin_desktop_restart_drain_if_requested()
|
|
9472
|
+
had_work = await self._poll_and_execute(
|
|
9473
|
+
accept_new_tasks=not draining_for_desktop_restart,
|
|
9474
|
+
)
|
|
9475
|
+
if draining_for_desktop_restart and not self.active_tasks:
|
|
9476
|
+
logger.info("Desktop daemon drain complete; restarting through the updated client")
|
|
9477
|
+
self._shutdown = True
|
|
9478
|
+
continue
|
|
9479
|
+
if self._shutdown:
|
|
9480
|
+
break
|
|
9274
9481
|
next_delay = self.poll_interval
|
|
9275
9482
|
if self.connections:
|
|
9276
9483
|
next_delay = min(
|
|
@@ -9289,7 +9496,7 @@ class RuntimeDaemon:
|
|
|
9289
9496
|
finally:
|
|
9290
9497
|
await self._shutdown_gracefully()
|
|
9291
9498
|
|
|
9292
|
-
async def _poll_and_execute(self) -> bool:
|
|
9499
|
+
async def _poll_and_execute(self, *, accept_new_tasks: bool = True) -> bool:
|
|
9293
9500
|
"""Poll for tasks from all connected servers and execute them."""
|
|
9294
9501
|
had_work = False
|
|
9295
9502
|
|
|
@@ -9327,11 +9534,11 @@ class RuntimeDaemon:
|
|
|
9327
9534
|
|
|
9328
9535
|
# Poll from all connected servers
|
|
9329
9536
|
for conn in self.connections:
|
|
9330
|
-
has_capacity = total_active < self.max_concurrent
|
|
9537
|
+
has_capacity = accept_new_tasks and total_active < self.max_concurrent
|
|
9331
9538
|
tasks = await conn.poller.poll() if has_capacity else []
|
|
9332
9539
|
# Always poll AI jobs so server-requested cancellations can stop a
|
|
9333
9540
|
# process that is itself consuming the last available slot.
|
|
9334
|
-
ai_jobs = await conn.poller.poll_ai_jobs()
|
|
9541
|
+
ai_jobs = await conn.poller.poll_ai_jobs() if accept_new_tasks else []
|
|
9335
9542
|
_cancel_server_requested_ai_jobs(conn)
|
|
9336
9543
|
total_active = len(self.active_tasks)
|
|
9337
9544
|
has_capacity = total_active < self.max_concurrent
|
|
@@ -9665,6 +9872,11 @@ class RuntimeDaemon:
|
|
|
9665
9872
|
# skipped for a node bound to the user's own real directory. See
|
|
9666
9873
|
# docs/designs/forgexa-ide-design.md §6.2.
|
|
9667
9874
|
is_local_bind = task.workspace_resolution == "local_bind"
|
|
9875
|
+
async def report_task_complete(result: TaskResult) -> None:
|
|
9876
|
+
if is_local_bind:
|
|
9877
|
+
_filter_local_bind_task_result(result)
|
|
9878
|
+
await reporter.report_complete(task.task_id, result)
|
|
9879
|
+
|
|
9668
9880
|
default_branch = str((task.project or {}).get("default_branch") or "").strip()
|
|
9669
9881
|
node_before_sha = ""
|
|
9670
9882
|
# Use workspace-level granularity (requirement_workflow_id or graph_id) so that
|
|
@@ -9697,7 +9909,7 @@ class RuntimeDaemon:
|
|
|
9697
9909
|
)
|
|
9698
9910
|
if local_bind_file_lock is None:
|
|
9699
9911
|
project_lock.release()
|
|
9700
|
-
await
|
|
9912
|
+
await report_task_complete(TaskResult(
|
|
9701
9913
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
9702
9914
|
error=(
|
|
9703
9915
|
"local_binding_unavailable: another process on this "
|
|
@@ -9724,7 +9936,7 @@ class RuntimeDaemon:
|
|
|
9724
9936
|
}
|
|
9725
9937
|
hint = _INSTALL_HINTS.get(task.agent_type, f"install the '{task.agent_type}' CLI tool")
|
|
9726
9938
|
logger.error("No agent found for type '%s' on this runtime", task.agent_type)
|
|
9727
|
-
await
|
|
9939
|
+
await report_task_complete(TaskResult(
|
|
9728
9940
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
9729
9941
|
error=(
|
|
9730
9942
|
f"Agent '{task.agent_type}' is not available on this runtime. "
|
|
@@ -9758,7 +9970,7 @@ class RuntimeDaemon:
|
|
|
9758
9970
|
_pf_checks = project_preflight.get("checks", [])
|
|
9759
9971
|
_pf_detail = _pf_checks[0].get("detail", "Project preflight failed") if _pf_checks else "Project preflight failed"
|
|
9760
9972
|
logger.warning("Project preflight failed for task %s: %s", task.task_id, _pf_detail)
|
|
9761
|
-
await
|
|
9973
|
+
await report_task_complete(TaskResult(
|
|
9762
9974
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
9763
9975
|
error=_pf_detail,
|
|
9764
9976
|
failure_code="project_preflight_failed",
|
|
@@ -9810,9 +10022,10 @@ class RuntimeDaemon:
|
|
|
9810
10022
|
# 3. Run agent with real-time output streaming + periodic progress heartbeat
|
|
9811
10023
|
await reporter.report_progress(task.task_id, 10, "running_agent")
|
|
9812
10024
|
|
|
9813
|
-
# Buffer
|
|
9814
|
-
#
|
|
10025
|
+
# Buffer cloud output or local-bind activity summaries between
|
|
10026
|
+
# ticks so the server accumulates a real-time, safe display log.
|
|
9815
10027
|
_line_buffer: list[str] = []
|
|
10028
|
+
last_local_activity: str | None = None
|
|
9816
10029
|
_task_start = time.monotonic()
|
|
9817
10030
|
progress_stop = asyncio.Event()
|
|
9818
10031
|
|
|
@@ -9821,18 +10034,43 @@ class RuntimeDaemon:
|
|
|
9821
10034
|
|
|
9822
10035
|
Forgexa IDE local-bind (design doc §6.6/§12.6.2): raw agent
|
|
9823
10036
|
terminal output must never leave the user's machine by
|
|
9824
|
-
default
|
|
9825
|
-
|
|
9826
|
-
|
|
10037
|
+
default. A content-free activity summary is sent through the
|
|
10038
|
+
dedicated activity_lines field so Web users can follow task
|
|
10039
|
+
progress without receiving stdout, commands, paths, or text.
|
|
9827
10040
|
Instead, append to a local-only, OS-user-private log file so
|
|
9828
10041
|
the IDE can still tail it on THIS machine (never sent over
|
|
9829
10042
|
HTTP by this module).
|
|
9830
10043
|
"""
|
|
10044
|
+
nonlocal last_local_activity
|
|
9831
10045
|
if is_local_bind:
|
|
9832
10046
|
_append_local_bind_output_log(task.task_id, lines)
|
|
10047
|
+
for activity in _summarize_local_bind_output(lines):
|
|
10048
|
+
if activity != last_local_activity:
|
|
10049
|
+
_line_buffer.append(activity)
|
|
10050
|
+
last_local_activity = activity
|
|
9833
10051
|
return
|
|
9834
10052
|
_line_buffer.extend(lines)
|
|
9835
10053
|
|
|
10054
|
+
async def report_stream_progress(
|
|
10055
|
+
progress_percent: int,
|
|
10056
|
+
current_step: str,
|
|
10057
|
+
output_lines: list[str],
|
|
10058
|
+
) -> None:
|
|
10059
|
+
if is_local_bind:
|
|
10060
|
+
await reporter.report_progress(
|
|
10061
|
+
task.task_id,
|
|
10062
|
+
progress_percent,
|
|
10063
|
+
current_step,
|
|
10064
|
+
activity_lines=output_lines,
|
|
10065
|
+
)
|
|
10066
|
+
else:
|
|
10067
|
+
await reporter.report_progress(
|
|
10068
|
+
task.task_id,
|
|
10069
|
+
progress_percent,
|
|
10070
|
+
current_step,
|
|
10071
|
+
output_lines=output_lines,
|
|
10072
|
+
)
|
|
10073
|
+
|
|
9836
10074
|
async def _progress_ticker():
|
|
9837
10075
|
"""Flush buffered output lines + update progress % every 1 s.
|
|
9838
10076
|
|
|
@@ -9856,9 +10094,7 @@ class RuntimeDaemon:
|
|
|
9856
10094
|
# Grab and clear the buffer atomically
|
|
9857
10095
|
lines_to_send = _line_buffer[:]
|
|
9858
10096
|
_line_buffer.clear()
|
|
9859
|
-
await
|
|
9860
|
-
task.task_id, pct, step, output_lines=lines_to_send
|
|
9861
|
-
)
|
|
10097
|
+
await report_stream_progress(pct, step, lines_to_send)
|
|
9862
10098
|
|
|
9863
10099
|
ticker_task = asyncio.create_task(_progress_ticker())
|
|
9864
10100
|
|
|
@@ -9877,9 +10113,7 @@ class RuntimeDaemon:
|
|
|
9877
10113
|
if _line_buffer:
|
|
9878
10114
|
pid = self.process_manager.active_processes.get(task.task_id)
|
|
9879
10115
|
step = f"running_agent (pid={pid.pid})" if pid else "running_agent"
|
|
9880
|
-
await
|
|
9881
|
-
task.task_id, 90, step, output_lines=_line_buffer[:]
|
|
9882
|
-
)
|
|
10116
|
+
await report_stream_progress(90, step, _line_buffer[:])
|
|
9883
10117
|
_line_buffer.clear()
|
|
9884
10118
|
|
|
9885
10119
|
tried_agents.add(agent.agent_id)
|
|
@@ -9978,9 +10212,7 @@ class RuntimeDaemon:
|
|
|
9978
10212
|
step += f" (pid={pid.pid})"
|
|
9979
10213
|
lines_to_send = _line_buffer[:]
|
|
9980
10214
|
_line_buffer.clear()
|
|
9981
|
-
await
|
|
9982
|
-
task.task_id, pct, step, output_lines=lines_to_send
|
|
9983
|
-
)
|
|
10215
|
+
await report_stream_progress(pct, step, lines_to_send)
|
|
9984
10216
|
|
|
9985
10217
|
ticker2 = asyncio.create_task(_progress_ticker2())
|
|
9986
10218
|
try:
|
|
@@ -9995,9 +10227,10 @@ class RuntimeDaemon:
|
|
|
9995
10227
|
except asyncio.CancelledError:
|
|
9996
10228
|
pass
|
|
9997
10229
|
if _line_buffer:
|
|
9998
|
-
await
|
|
9999
|
-
|
|
10000
|
-
|
|
10230
|
+
await report_stream_progress(
|
|
10231
|
+
90,
|
|
10232
|
+
f"running_agent:{agent.agent_id}",
|
|
10233
|
+
_line_buffer[:],
|
|
10001
10234
|
)
|
|
10002
10235
|
_line_buffer.clear()
|
|
10003
10236
|
|
|
@@ -10480,19 +10713,14 @@ class RuntimeDaemon:
|
|
|
10480
10713
|
or "validation_retry_exhausted"
|
|
10481
10714
|
)
|
|
10482
10715
|
result.execution_attempt_id = task.execution_attempt_id
|
|
10483
|
-
if is_local_bind:
|
|
10484
|
-
# See docs/designs/forgexa-ide-design.md §6.6 — full terminal
|
|
10485
|
-
# output and raw agent observations must never leave the
|
|
10486
|
-
# user's machine by default.
|
|
10487
|
-
_filter_local_bind_task_result(result)
|
|
10488
10716
|
await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
|
|
10489
|
-
await
|
|
10717
|
+
await report_task_complete(result)
|
|
10490
10718
|
|
|
10491
10719
|
logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
|
|
10492
10720
|
|
|
10493
10721
|
except Exception as e:
|
|
10494
10722
|
logger.exception("Task %s failed with exception", task.task_id)
|
|
10495
|
-
await
|
|
10723
|
+
await report_task_complete(TaskResult(
|
|
10496
10724
|
status="failed", exit_code=-1, stdout="", stderr="",
|
|
10497
10725
|
error=str(e),
|
|
10498
10726
|
execution_attempt_id=task.execution_attempt_id,
|
|
@@ -15394,7 +15622,9 @@ class RuntimeDaemon:
|
|
|
15394
15622
|
for conn in self.connections:
|
|
15395
15623
|
await conn.stop()
|
|
15396
15624
|
|
|
15397
|
-
|
|
15625
|
+
if not getattr(self, "_desktop_restart_requested", False):
|
|
15626
|
+
_clear_local_runtime_registration_snapshot(self._local_runtime_instance_id)
|
|
15627
|
+
_clear_desktop_daemon_restart_request(self._local_runtime_instance_id)
|
|
15398
15628
|
logger.info("Daemon stopped.")
|
|
15399
15629
|
|
|
15400
15630
|
def handle_signal(self, sig):
|
|
@@ -79,6 +79,8 @@ except ImportError:
|
|
|
79
79
|
|
|
80
80
|
# Module-level override applied by main() when --server-url is passed.
|
|
81
81
|
_SERVER_URL_OVERRIDE: str | None = None
|
|
82
|
+
_OFFICIAL_PYPI_BASE_URL = "https://pypi.org"
|
|
83
|
+
_OFFICIAL_PYPI_SIMPLE_INDEX_URL = f"{_OFFICIAL_PYPI_BASE_URL}/simple"
|
|
82
84
|
|
|
83
85
|
|
|
84
86
|
# ── Banner ────────────────────────────────────────────────────────────────────
|
|
@@ -924,9 +926,22 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
|
|
|
924
926
|
"Use `pipx upgrade forgexa-cli` manually after restoring your pipx installation."
|
|
925
927
|
)
|
|
926
928
|
if target_version:
|
|
927
|
-
command = [
|
|
929
|
+
command = [
|
|
930
|
+
pipx,
|
|
931
|
+
"install",
|
|
932
|
+
"--force",
|
|
933
|
+
"--index-url",
|
|
934
|
+
_OFFICIAL_PYPI_SIMPLE_INDEX_URL,
|
|
935
|
+
_package_spec_for_version(target_version),
|
|
936
|
+
]
|
|
928
937
|
else:
|
|
929
|
-
command = [
|
|
938
|
+
command = [
|
|
939
|
+
pipx,
|
|
940
|
+
"upgrade",
|
|
941
|
+
"--index-url",
|
|
942
|
+
_OFFICIAL_PYPI_SIMPLE_INDEX_URL,
|
|
943
|
+
"forgexa-cli",
|
|
944
|
+
]
|
|
930
945
|
return UpgradePlan(
|
|
931
946
|
installer="pipx",
|
|
932
947
|
command=command,
|
|
@@ -940,7 +955,15 @@ def _build_upgrade_plan(target_version: str | None) -> UpgradePlan:
|
|
|
940
955
|
"Reinstall forgexa-cli with pipx or a Python environment that includes pip."
|
|
941
956
|
)
|
|
942
957
|
|
|
943
|
-
command = [
|
|
958
|
+
command = [
|
|
959
|
+
python,
|
|
960
|
+
"-m",
|
|
961
|
+
"pip",
|
|
962
|
+
"install",
|
|
963
|
+
"--upgrade",
|
|
964
|
+
"--index-url",
|
|
965
|
+
_OFFICIAL_PYPI_SIMPLE_INDEX_URL,
|
|
966
|
+
]
|
|
944
967
|
if _is_user_site_install(dist_root) and not _is_virtualenv():
|
|
945
968
|
command.append("--user")
|
|
946
969
|
if _is_pep668_environment():
|
|
@@ -1023,7 +1046,7 @@ def _latest_published_cli_version() -> str:
|
|
|
1023
1046
|
import httpx
|
|
1024
1047
|
|
|
1025
1048
|
with httpx.Client(timeout=5) as client:
|
|
1026
|
-
response = client.get("
|
|
1049
|
+
response = client.get(f"{_OFFICIAL_PYPI_BASE_URL}/pypi/forgexa-cli/json")
|
|
1027
1050
|
response.raise_for_status()
|
|
1028
1051
|
version = _normalize_target_version(str(response.json()["info"]["version"]))
|
|
1029
1052
|
except Exception as exc:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgexa-cli
|
|
3
|
-
Version: 1.47.
|
|
3
|
+
Version: 1.47.4
|
|
4
4
|
Summary: Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform
|
|
5
5
|
Author-email: Jason Sun <dev.winds@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -132,10 +132,16 @@ forgexa upgrade --target-version 1.12.3
|
|
|
132
132
|
|
|
133
133
|
The command detects how `forgexa-cli` was installed:
|
|
134
134
|
|
|
135
|
-
- `pipx` install: uses `pipx upgrade forgexa-cli`, or `pipx install --force forgexa-cli==<version>` for a pinned version
|
|
136
|
-
- `pip` install: uses `python -m pip install --upgrade forgexa-cli`
|
|
135
|
+
- `pipx` install: uses `pipx upgrade --index-url https://pypi.org/simple forgexa-cli`, or `pipx install --force --index-url https://pypi.org/simple forgexa-cli==<version>` for a pinned version
|
|
136
|
+
- `pip` install: uses `python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli`
|
|
137
137
|
- non-editable local path install (`pip install /path/to/cli`): follows the same `pip` upgrade path and switches to the published PyPI release
|
|
138
138
|
|
|
139
|
+
Self-upgrades pass `--index-url https://pypi.org/simple` to pip or pipx, matching the official PyPI release check and avoiding a configured mirror that has not yet synchronized a new release. A direct `pip install --upgrade forgexa-cli` command always follows that Python environment's pip configuration. When its mirror is behind, upgrade manually with:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python -m pip install --upgrade --index-url https://pypi.org/simple forgexa-cli==1.12.3
|
|
143
|
+
```
|
|
144
|
+
|
|
139
145
|
For safety, `forgexa upgrade` stops any running local daemon before upgrading. If the daemon was previously started in background mode, the CLI starts it again automatically after a successful upgrade; otherwise it prints a manual restart hint.
|
|
140
146
|
|
|
141
147
|
`forgexa upgrade` supports standard PyPI / pipx installs and non-editable local path installs. Editable installs, VCS installs, and other direct URL installs are rejected with a manual upgrade hint.
|
|
@@ -575,7 +575,13 @@ def test_build_upgrade_plan_for_pipx_latest(monkeypatch: pytest.MonkeyPatch) ->
|
|
|
575
575
|
plan = main._build_upgrade_plan(None)
|
|
576
576
|
|
|
577
577
|
assert plan.installer == "pipx"
|
|
578
|
-
assert plan.command == [
|
|
578
|
+
assert plan.command == [
|
|
579
|
+
"/usr/local/bin/pipx",
|
|
580
|
+
"upgrade",
|
|
581
|
+
"--index-url",
|
|
582
|
+
"https://pypi.org/simple",
|
|
583
|
+
"forgexa-cli",
|
|
584
|
+
]
|
|
579
585
|
|
|
580
586
|
|
|
581
587
|
def test_build_upgrade_plan_for_pipx_pinned_version(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -589,7 +595,14 @@ def test_build_upgrade_plan_for_pipx_pinned_version(monkeypatch: pytest.MonkeyPa
|
|
|
589
595
|
plan = main._build_upgrade_plan("1.12.3")
|
|
590
596
|
|
|
591
597
|
assert plan.installer == "pipx"
|
|
592
|
-
assert plan.command == [
|
|
598
|
+
assert plan.command == [
|
|
599
|
+
"/usr/local/bin/pipx",
|
|
600
|
+
"install",
|
|
601
|
+
"--force",
|
|
602
|
+
"--index-url",
|
|
603
|
+
"https://pypi.org/simple",
|
|
604
|
+
"forgexa-cli==1.12.3",
|
|
605
|
+
]
|
|
593
606
|
|
|
594
607
|
|
|
595
608
|
def test_build_upgrade_plan_for_pip_user_site(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
@@ -613,6 +626,8 @@ def test_build_upgrade_plan_for_pip_user_site(monkeypatch: pytest.MonkeyPatch) -
|
|
|
613
626
|
"pip",
|
|
614
627
|
"install",
|
|
615
628
|
"--upgrade",
|
|
629
|
+
"--index-url",
|
|
630
|
+
"https://pypi.org/simple",
|
|
616
631
|
"--user",
|
|
617
632
|
"forgexa-cli==1.12.3",
|
|
618
633
|
]
|
|
@@ -634,6 +649,8 @@ def test_build_upgrade_plan_adds_break_system_packages_on_pep668(monkeypatch: py
|
|
|
634
649
|
|
|
635
650
|
assert "--break-system-packages" in plan.command
|
|
636
651
|
assert "--user" in plan.command
|
|
652
|
+
assert "--index-url" in plan.command
|
|
653
|
+
assert plan.command[plan.command.index("--index-url") + 1] == "https://pypi.org/simple"
|
|
637
654
|
assert plan.command[-1] == "forgexa-cli"
|
|
638
655
|
|
|
639
656
|
|
|
@@ -666,6 +683,8 @@ def test_build_upgrade_plan_allows_non_editable_local_path_installs(monkeypatch:
|
|
|
666
683
|
"pip",
|
|
667
684
|
"install",
|
|
668
685
|
"--upgrade",
|
|
686
|
+
"--index-url",
|
|
687
|
+
"https://pypi.org/simple",
|
|
669
688
|
"--user",
|
|
670
689
|
"--break-system-packages",
|
|
671
690
|
"forgexa-cli",
|
|
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
|