forgexa-cli 1.47.2__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.2 → forgexa_cli-1.47.4}/PKG-INFO +9 -3
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/README.md +8 -2
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/daemon.py +283 -53
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/main.py +73 -12
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/runtime_evidence.py +23 -11
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/PKG-INFO +9 -3
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/pyproject.toml +1 -1
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_auth_and_runtime_commands.py +117 -2
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/setup.cfg +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.47.2 → 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
|
|
|
@@ -7908,7 +8083,7 @@ class ServerConnection:
|
|
|
7908
8083
|
"capabilities": {
|
|
7909
8084
|
"platform": platform.platform(),
|
|
7910
8085
|
"python_version": platform.python_version(),
|
|
7911
|
-
"local_workspace_bindings": {"protocol_version":
|
|
8086
|
+
"local_workspace_bindings": {"protocol_version": 3},
|
|
7912
8087
|
},
|
|
7913
8088
|
"server_url": self.server_url,
|
|
7914
8089
|
"expires_in_days": 90,
|
|
@@ -8306,7 +8481,7 @@ class ServerConnection:
|
|
|
8306
8481
|
"capabilities": {
|
|
8307
8482
|
"platform": platform.platform(),
|
|
8308
8483
|
"python_version": platform.python_version(),
|
|
8309
|
-
"local_workspace_bindings": {"protocol_version":
|
|
8484
|
+
"local_workspace_bindings": {"protocol_version": 3},
|
|
8310
8485
|
"local_evidence": {"protocol_version": 1, "structured_summary": True},
|
|
8311
8486
|
"local_evidence": {"protocol_version": 1, "structured_summary": True},
|
|
8312
8487
|
},
|
|
@@ -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():
|
|
@@ -1017,16 +1040,29 @@ def _version_is_newer(candidate: str, reference: str) -> bool:
|
|
|
1017
1040
|
return False
|
|
1018
1041
|
|
|
1019
1042
|
|
|
1020
|
-
def
|
|
1021
|
-
"""
|
|
1043
|
+
def _latest_published_cli_version() -> str:
|
|
1044
|
+
"""Return the current forgexa-cli version from the official PyPI API."""
|
|
1022
1045
|
try:
|
|
1023
1046
|
import httpx
|
|
1024
1047
|
|
|
1025
1048
|
with httpx.Client(timeout=5) as client:
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1049
|
+
response = client.get(f"{_OFFICIAL_PYPI_BASE_URL}/pypi/forgexa-cli/json")
|
|
1050
|
+
response.raise_for_status()
|
|
1051
|
+
version = _normalize_target_version(str(response.json()["info"]["version"]))
|
|
1052
|
+
except Exception as exc:
|
|
1053
|
+
raise RuntimeError(
|
|
1054
|
+
"Could not determine the latest forgexa-cli release from PyPI. "
|
|
1055
|
+
"Check access to pypi.org or specify --target-version."
|
|
1056
|
+
) from exc
|
|
1057
|
+
if version is None:
|
|
1058
|
+
raise RuntimeError("PyPI did not return a forgexa-cli version.")
|
|
1059
|
+
return version
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _run_update_check() -> None:
|
|
1063
|
+
"""Fetch the latest published CLI version and refresh the local cache."""
|
|
1064
|
+
try:
|
|
1065
|
+
_write_update_cache(_latest_published_cli_version())
|
|
1030
1066
|
except Exception:
|
|
1031
1067
|
pass # Network errors are silently ignored
|
|
1032
1068
|
|
|
@@ -1581,13 +1617,15 @@ def _run_windows_upgrade_worker(payload: str) -> None:
|
|
|
1581
1617
|
parent_pid = int(data["parent_pid"])
|
|
1582
1618
|
launcher_paths = data.get("launcher_paths", [])
|
|
1583
1619
|
daemon_stopped = bool(data.get("daemon_stopped"))
|
|
1584
|
-
target_version = str(data.get("target_version") or "
|
|
1620
|
+
target_version = str(data.get("target_version") or "").strip()
|
|
1585
1621
|
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
1586
1622
|
return
|
|
1587
1623
|
if not isinstance(command, list) or not all(isinstance(part, str) for part in command):
|
|
1588
1624
|
return
|
|
1589
1625
|
if not isinstance(launcher_paths, list) or not all(isinstance(path, str) for path in launcher_paths):
|
|
1590
1626
|
return
|
|
1627
|
+
if not target_version:
|
|
1628
|
+
return
|
|
1591
1629
|
|
|
1592
1630
|
try:
|
|
1593
1631
|
log_path = _upgrade_log_path()
|
|
@@ -1608,6 +1646,13 @@ def _run_windows_upgrade_worker(payload: str) -> None:
|
|
|
1608
1646
|
if result.returncode != 0:
|
|
1609
1647
|
log_file.write(f"Upgrade failed (returncode={result.returncode}).\n")
|
|
1610
1648
|
return
|
|
1649
|
+
installed_version = _installed_cli_version()
|
|
1650
|
+
if installed_version != target_version:
|
|
1651
|
+
log_file.write(
|
|
1652
|
+
f"Upgrade failed: expected {target_version}, "
|
|
1653
|
+
f"installed {installed_version or 'unknown'}.\n"
|
|
1654
|
+
)
|
|
1655
|
+
return
|
|
1611
1656
|
log_file.write("Upgrade complete.\n")
|
|
1612
1657
|
if daemon_stopped:
|
|
1613
1658
|
restarted, server_url = _start_daemon_after_upgrade()
|
|
@@ -3446,12 +3491,14 @@ def cmd_version(args: argparse.Namespace) -> None:
|
|
|
3446
3491
|
def cmd_upgrade(args: argparse.Namespace) -> None:
|
|
3447
3492
|
try:
|
|
3448
3493
|
target_version = _normalize_target_version(getattr(args, "target_version", None))
|
|
3494
|
+
if target_version is None:
|
|
3495
|
+
target_version = _latest_published_cli_version()
|
|
3449
3496
|
plan = _build_upgrade_plan(target_version)
|
|
3450
3497
|
except (RuntimeError, ValueError) as exc:
|
|
3451
3498
|
print(str(exc), file=sys.stderr)
|
|
3452
3499
|
sys.exit(1)
|
|
3453
3500
|
|
|
3454
|
-
if target_version
|
|
3501
|
+
if target_version == plan.current_version:
|
|
3455
3502
|
print(f"forgexa-cli {plan.current_version} is already installed.")
|
|
3456
3503
|
return
|
|
3457
3504
|
|
|
@@ -3470,7 +3517,7 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
|
|
|
3470
3517
|
print(f"Stopped local daemon (PID {daemon_pid}) before upgrade.")
|
|
3471
3518
|
|
|
3472
3519
|
print(f"Current version : {plan.current_version}")
|
|
3473
|
-
print(f"Target version : {target_version
|
|
3520
|
+
print(f"Target version : {target_version}")
|
|
3474
3521
|
print(f"Upgrade mode : {plan.installer}")
|
|
3475
3522
|
print(f"Running : {shlex.join(plan.command)}")
|
|
3476
3523
|
|
|
@@ -3494,6 +3541,20 @@ def cmd_upgrade(args: argparse.Namespace) -> None:
|
|
|
3494
3541
|
sys.exit(result.returncode or 1)
|
|
3495
3542
|
|
|
3496
3543
|
installed_version = _installed_cli_version() or "unknown"
|
|
3544
|
+
if installed_version != target_version:
|
|
3545
|
+
print(
|
|
3546
|
+
f"Upgrade did not install the requested version {target_version}; "
|
|
3547
|
+
f"installed version is {installed_version}. "
|
|
3548
|
+
"The configured package index, proxy, or constraint may be stale.",
|
|
3549
|
+
file=sys.stderr,
|
|
3550
|
+
)
|
|
3551
|
+
if daemon_stopped:
|
|
3552
|
+
print(
|
|
3553
|
+
"The local daemon was stopped before the upgrade attempt. "
|
|
3554
|
+
"Restart it manually after resolving the package source issue.",
|
|
3555
|
+
file=sys.stderr,
|
|
3556
|
+
)
|
|
3557
|
+
sys.exit(1)
|
|
3497
3558
|
print(f"Upgrade complete. Installed version: {installed_version}")
|
|
3498
3559
|
if daemon_stopped:
|
|
3499
3560
|
restarted, server_url = _start_daemon_after_upgrade()
|
|
@@ -14,7 +14,6 @@ import xml.etree.ElementTree as ElementTree
|
|
|
14
14
|
from pathlib import Path
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
|
-
|
|
18
17
|
RUNTIME_EVIDENCE_PLAN_VERSION = 1
|
|
19
18
|
TEST_COVERAGE_CHECK_KIND = "test_coverage"
|
|
20
19
|
STATIC_CHECK_KIND = "static_check"
|
|
@@ -25,6 +24,7 @@ LOCAL_EVIDENCE_SUMMARY_VERSION = 1
|
|
|
25
24
|
_DEFAULT_TIMEOUT_SECONDS = 600
|
|
26
25
|
_MAX_FAILED_TESTS = 20
|
|
27
26
|
_MAX_LINT_ISSUES = 50
|
|
27
|
+
_MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS = 200
|
|
28
28
|
_MAX_OUTPUT_CHARS = 100_000
|
|
29
29
|
_MAX_TEST_CASE_RESULTS = 200
|
|
30
30
|
_SHELL_METACHARACTERS = (";", "`", "$", ">", "<", "&", "|", "\n", "\r", "\x00")
|
|
@@ -53,6 +53,13 @@ _SAFE_ACCEPTANCE_REFERENCE = re.compile(r"^AC-(\d+)$", re.IGNORECASE)
|
|
|
53
53
|
_CANONICAL_TEST_CASE_ID = re.compile(r"^TC-(?:00[1-9]|0[1-9]\d|[1-9]\d{2})$", re.IGNORECASE)
|
|
54
54
|
_TEST_CASE_MARKER = re.compile(r"(?<![A-Za-z0-9_-])TC-(\d+)(?![A-Za-z0-9_-])", re.IGNORECASE)
|
|
55
55
|
_SAFE_PYTHON_IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
56
|
+
_COVERAGE_DECLARED_OUTCOMES = frozenset({
|
|
57
|
+
"covered",
|
|
58
|
+
"partial",
|
|
59
|
+
"uncovered",
|
|
60
|
+
"unverifiable",
|
|
61
|
+
"not_applicable",
|
|
62
|
+
})
|
|
56
63
|
_MYPY_DIAGNOSTIC = re.compile(
|
|
57
64
|
r"^(?P<file>[^:\n]+):(?P<line>[1-9]\d*)(?::(?P<column>[1-9]\d*))?: "
|
|
58
65
|
r"error: (?P<message>.+?)(?:\s+\[(?P<code>[A-Za-z0-9_-]+)\])?\s*$"
|
|
@@ -711,7 +718,7 @@ def parse_acceptance_criteria_coverage_artifacts(
|
|
|
711
718
|
return _acceptance_criteria_coverage_failure("acceptance_criteria_json_invalid_shape")
|
|
712
719
|
|
|
713
720
|
test_case_intents: dict[str, str] = {}
|
|
714
|
-
for raw_test_case in test_cases_document["test_cases"][:
|
|
721
|
+
for raw_test_case in test_cases_document["test_cases"][:_MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS]:
|
|
715
722
|
if not isinstance(raw_test_case, dict):
|
|
716
723
|
return _acceptance_criteria_coverage_failure("test_case_invalid")
|
|
717
724
|
test_case_id = raw_test_case.get("id")
|
|
@@ -725,24 +732,28 @@ def parse_acceptance_criteria_coverage_artifacts(
|
|
|
725
732
|
):
|
|
726
733
|
return _acceptance_criteria_coverage_failure("test_case_invalid")
|
|
727
734
|
test_case_intents[test_case_id.strip()] = intent_id.strip()
|
|
728
|
-
if len(test_cases_document["test_cases"]) >
|
|
735
|
+
if len(test_cases_document["test_cases"]) > _MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS:
|
|
729
736
|
return _acceptance_criteria_coverage_failure("test_case_limit_exceeded")
|
|
730
737
|
|
|
731
738
|
coverage_links: list[dict[str, Any]] = []
|
|
732
739
|
seen_acceptance_ids: set[str] = set()
|
|
733
|
-
for raw_criterion in coverage_matrix_document["acceptance_criteria"][:
|
|
740
|
+
for raw_criterion in coverage_matrix_document["acceptance_criteria"][:_MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS]:
|
|
734
741
|
if not isinstance(raw_criterion, dict):
|
|
735
742
|
return _acceptance_criteria_coverage_failure("coverage_matrix_invalid")
|
|
736
743
|
acceptance_id = _canonical_acceptance_criteria_reference(raw_criterion.get("id"))
|
|
737
|
-
|
|
738
|
-
|
|
744
|
+
raw_status = raw_criterion.get("status")
|
|
745
|
+
declared_outcome = raw_status.strip().lower() if isinstance(raw_status, str) else ""
|
|
746
|
+
covered_by = raw_criterion.get("covered_by", [])
|
|
739
747
|
if (
|
|
740
748
|
acceptance_id is None
|
|
741
749
|
or acceptance_id in seen_acceptance_ids
|
|
742
|
-
or not isinstance(status, str)
|
|
743
|
-
or status.strip().lower() != "covered"
|
|
744
750
|
or not isinstance(covered_by, list)
|
|
745
|
-
or not
|
|
751
|
+
or declared_outcome not in _COVERAGE_DECLARED_OUTCOMES
|
|
752
|
+
or (declared_outcome == "covered" and not covered_by)
|
|
753
|
+
or (
|
|
754
|
+
declared_outcome in {"uncovered", "unverifiable", "not_applicable"}
|
|
755
|
+
and covered_by
|
|
756
|
+
)
|
|
746
757
|
):
|
|
747
758
|
return _acceptance_criteria_coverage_failure("coverage_matrix_invalid")
|
|
748
759
|
test_case_ids: list[str] = []
|
|
@@ -760,8 +771,9 @@ def parse_acceptance_criteria_coverage_artifacts(
|
|
|
760
771
|
"acceptance_criteria_ref": acceptance_id,
|
|
761
772
|
"test_case_ids": test_case_ids,
|
|
762
773
|
"test_intent_ids": [test_case_intents[test_case_id] for test_case_id in test_case_ids],
|
|
774
|
+
"declared_outcome": declared_outcome,
|
|
763
775
|
})
|
|
764
|
-
if len(coverage_matrix_document["acceptance_criteria"]) >
|
|
776
|
+
if len(coverage_matrix_document["acceptance_criteria"]) > _MAX_ACCEPTANCE_CRITERIA_COVERAGE_ITEMS:
|
|
765
777
|
return _acceptance_criteria_coverage_failure("coverage_matrix_limit_exceeded")
|
|
766
778
|
return {
|
|
767
779
|
"status": "completed",
|
|
@@ -1449,4 +1461,4 @@ def _security_score(high: int, medium: int, low: int) -> float:
|
|
|
1449
1461
|
return 0.8
|
|
1450
1462
|
if low > 0:
|
|
1451
1463
|
return max(0.85, 1.0 - low * 0.03)
|
|
1452
|
-
return 1.0
|
|
1464
|
+
return 1.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.
|
|
@@ -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",
|
|
@@ -730,6 +749,66 @@ def test_cmd_upgrade_runs_planned_command(monkeypatch: pytest.MonkeyPatch) -> No
|
|
|
730
749
|
assert "Upgrade complete. Installed version: 1.12.3" in output
|
|
731
750
|
|
|
732
751
|
|
|
752
|
+
def test_cmd_upgrade_resolves_latest_to_an_explicit_pypi_version(
|
|
753
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
754
|
+
) -> None:
|
|
755
|
+
plan = main.UpgradePlan(
|
|
756
|
+
installer="pipx",
|
|
757
|
+
command=["pipx", "install", "--force", "forgexa-cli==1.12.3"],
|
|
758
|
+
current_version="1.12.2",
|
|
759
|
+
target_version="1.12.3",
|
|
760
|
+
)
|
|
761
|
+
calls: list[list[str]] = []
|
|
762
|
+
|
|
763
|
+
def build_upgrade_plan(target_version: str | None) -> main.UpgradePlan:
|
|
764
|
+
assert target_version == "1.12.3"
|
|
765
|
+
return plan
|
|
766
|
+
|
|
767
|
+
monkeypatch.setattr(main, "_latest_published_cli_version", lambda: "1.12.3")
|
|
768
|
+
monkeypatch.setattr(main, "_build_upgrade_plan", build_upgrade_plan)
|
|
769
|
+
monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (False, None))
|
|
770
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.3")
|
|
771
|
+
monkeypatch.setattr(main, "_start_daemon_after_upgrade", lambda: (False, None))
|
|
772
|
+
monkeypatch.setattr(
|
|
773
|
+
main.subprocess,
|
|
774
|
+
"run",
|
|
775
|
+
lambda command: calls.append(command) or SimpleNamespace(returncode=0),
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
buffer = io.StringIO()
|
|
779
|
+
with contextlib.redirect_stdout(buffer):
|
|
780
|
+
main.cmd_upgrade(SimpleNamespace(target_version=None))
|
|
781
|
+
|
|
782
|
+
assert calls == [["pipx", "install", "--force", "forgexa-cli==1.12.3"]]
|
|
783
|
+
assert "Target version : 1.12.3" in buffer.getvalue()
|
|
784
|
+
|
|
785
|
+
|
|
786
|
+
def test_cmd_upgrade_rejects_success_when_target_version_is_not_installed(
|
|
787
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
788
|
+
capsys: pytest.CaptureFixture[str],
|
|
789
|
+
) -> None:
|
|
790
|
+
plan = main.UpgradePlan(
|
|
791
|
+
installer="pipx",
|
|
792
|
+
command=["pipx", "install", "--force", "forgexa-cli==1.12.3"],
|
|
793
|
+
current_version="1.12.2",
|
|
794
|
+
target_version="1.12.3",
|
|
795
|
+
)
|
|
796
|
+
restart_calls: list[bool] = []
|
|
797
|
+
|
|
798
|
+
monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
|
|
799
|
+
monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
|
|
800
|
+
monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (True, 4242))
|
|
801
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.2")
|
|
802
|
+
monkeypatch.setattr(main, "_start_daemon_after_upgrade", lambda: restart_calls.append(True))
|
|
803
|
+
monkeypatch.setattr(main.subprocess, "run", lambda command: SimpleNamespace(returncode=0))
|
|
804
|
+
|
|
805
|
+
with pytest.raises(SystemExit, match="1"):
|
|
806
|
+
main.cmd_upgrade(SimpleNamespace(target_version="1.12.3"))
|
|
807
|
+
|
|
808
|
+
assert restart_calls == []
|
|
809
|
+
assert "did not install the requested version 1.12.3" in capsys.readouterr().err
|
|
810
|
+
|
|
811
|
+
|
|
733
812
|
def test_cmd_upgrade_defers_windows_install_until_cli_exits(
|
|
734
813
|
monkeypatch: pytest.MonkeyPatch,
|
|
735
814
|
) -> None:
|
|
@@ -812,6 +891,7 @@ def test_windows_upgrade_worker_waits_before_running_pip(
|
|
|
812
891
|
"run",
|
|
813
892
|
lambda command, **kwargs: events.append("run") or SimpleNamespace(returncode=0),
|
|
814
893
|
)
|
|
894
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.3")
|
|
815
895
|
|
|
816
896
|
main._run_windows_upgrade_worker(
|
|
817
897
|
json.dumps(
|
|
@@ -838,6 +918,7 @@ def test_windows_upgrade_worker_restarts_stopped_daemon_after_success(
|
|
|
838
918
|
monkeypatch.setattr(main, "_upgrade_log_path", lambda: log_path)
|
|
839
919
|
monkeypatch.setattr(main, "_wait_for_windows_upgrade_release", lambda *args: True)
|
|
840
920
|
monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
|
|
921
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.3")
|
|
841
922
|
monkeypatch.setattr(
|
|
842
923
|
main,
|
|
843
924
|
"_start_daemon_after_upgrade",
|
|
@@ -860,6 +941,38 @@ def test_windows_upgrade_worker_restarts_stopped_daemon_after_success(
|
|
|
860
941
|
assert "Daemon restarted in background (server=https://api.example.com)." in log_path.read_text()
|
|
861
942
|
|
|
862
943
|
|
|
944
|
+
def test_windows_upgrade_worker_does_not_restart_when_version_is_not_installed(
|
|
945
|
+
tmp_path: Path,
|
|
946
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
947
|
+
) -> None:
|
|
948
|
+
log_path = tmp_path / "upgrade.log"
|
|
949
|
+
restart_calls: list[bool] = []
|
|
950
|
+
monkeypatch.setattr(main, "_upgrade_log_path", lambda: log_path)
|
|
951
|
+
monkeypatch.setattr(main, "_wait_for_windows_upgrade_release", lambda *args: True)
|
|
952
|
+
monkeypatch.setattr(main.subprocess, "run", lambda *args, **kwargs: SimpleNamespace(returncode=0))
|
|
953
|
+
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.12.2")
|
|
954
|
+
monkeypatch.setattr(
|
|
955
|
+
main,
|
|
956
|
+
"_start_daemon_after_upgrade",
|
|
957
|
+
lambda: restart_calls.append(True) or (True, "https://api.example.com"),
|
|
958
|
+
)
|
|
959
|
+
|
|
960
|
+
main._run_windows_upgrade_worker(
|
|
961
|
+
json.dumps(
|
|
962
|
+
{
|
|
963
|
+
"command": ["python", "-m", "pip", "install", "--upgrade", "forgexa-cli"],
|
|
964
|
+
"parent_pid": 4242,
|
|
965
|
+
"launcher_paths": [],
|
|
966
|
+
"daemon_stopped": True,
|
|
967
|
+
"target_version": "1.12.3",
|
|
968
|
+
}
|
|
969
|
+
)
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
assert restart_calls == []
|
|
973
|
+
assert "Upgrade failed: expected 1.12.3, installed 1.12.2." in log_path.read_text()
|
|
974
|
+
|
|
975
|
+
|
|
863
976
|
def test_cmd_upgrade_restarts_background_daemon_after_success(
|
|
864
977
|
monkeypatch: pytest.MonkeyPatch,
|
|
865
978
|
) -> None:
|
|
@@ -873,6 +986,7 @@ def test_cmd_upgrade_restarts_background_daemon_after_success(
|
|
|
873
986
|
|
|
874
987
|
monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
|
|
875
988
|
monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
|
|
989
|
+
monkeypatch.setattr(main, "_latest_published_cli_version", lambda: "1.16.4")
|
|
876
990
|
monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (True, 4242))
|
|
877
991
|
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.16.4")
|
|
878
992
|
monkeypatch.setattr(main.subprocess, "run", lambda cmd: SimpleNamespace(returncode=0))
|
|
@@ -905,6 +1019,7 @@ def test_cmd_upgrade_keeps_manual_restart_hint_when_auto_restart_skips(
|
|
|
905
1019
|
|
|
906
1020
|
monkeypatch.setattr(main, "_build_upgrade_plan", lambda target: plan)
|
|
907
1021
|
monkeypatch.setattr(main, "_normalize_target_version", lambda value: value)
|
|
1022
|
+
monkeypatch.setattr(main, "_latest_published_cli_version", lambda: "1.16.4")
|
|
908
1023
|
monkeypatch.setattr(main, "_stop_daemon_if_running", lambda: (True, 4242))
|
|
909
1024
|
monkeypatch.setattr(main, "_installed_cli_version", lambda: "1.16.4")
|
|
910
1025
|
monkeypatch.setattr(main.subprocess, "run", lambda cmd: SimpleNamespace(returncode=0))
|
|
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
|