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.
Files changed (28) hide show
  1. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/PKG-INFO +9 -3
  2. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/README.md +8 -2
  3. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/__init__.py +1 -1
  4. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/daemon.py +283 -53
  5. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/main.py +73 -12
  6. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/runtime_evidence.py +23 -11
  7. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/PKG-INFO +9 -3
  8. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/pyproject.toml +1 -1
  9. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_auth_and_runtime_commands.py +117 -2
  10. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/_build_config.py +0 -0
  11. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/_local_bind.py +0 -0
  12. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/agent_core.py +0 -0
  13. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/autoupgrade.py +0 -0
  14. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli/py.typed +0 -0
  15. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/setup.cfg +0 -0
  21. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.47.2 → forgexa_cli-1.47.4}/tests/test_silent_install.py +0 -0
  28. {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.2
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"
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 + ".tmp")
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.2"
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. error/failure_code/metrics/git/artifacts/evidence_summary are left as-is
1512
- (status metadata, not raw output) only stdout/stderr tail and raw
1513
- agent observations are cleared. Mutates `result` in place; call this
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 = output_lines or []
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
- self._last_progress_sent[task_id] = {
7119
- "progress_percent": progress_percent,
7120
- "current_step": current_step,
7121
- "sent_at": time.monotonic(),
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.debug("AIJob poll error: %s", _format_exc(e))
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": 2},
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": 2},
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
- had_work = await self._poll_and_execute()
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 reporter.report_complete(task.task_id, TaskResult(
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 reporter.report_complete(task.task_id, TaskResult(
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 reporter.report_complete(task.task_id, TaskResult(
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 agent output lines between ticks; the ticker flushes them
9814
- # every 10 seconds so the server accumulates a real-time log.
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 skip buffering into the server-bound progress
9825
- report entirely (every report_progress() flush below sends
9826
- an empty output_lines list, not live stdout content).
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 reporter.report_progress(
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 reporter.report_progress(
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 reporter.report_progress(
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 reporter.report_progress(
9999
- task.task_id, 90, f"running_agent:{agent.agent_id}",
10000
- output_lines=_line_buffer[:],
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 reporter.report_complete(task.task_id, result)
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 reporter.report_complete(task.task_id, TaskResult(
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
- _clear_local_runtime_registration_snapshot(self._local_runtime_instance_id)
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 = [pipx, "install", "--force", _package_spec_for_version(target_version)]
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 = [pipx, "upgrade", "forgexa-cli"]
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 = [python, "-m", "pip", "install", "--upgrade"]
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 _run_update_check() -> None:
1021
- """Fetch the latest published CLI version and refresh the local cache."""
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
- resp = client.get("https://pypi.org/pypi/forgexa-cli/json")
1027
- resp.raise_for_status()
1028
- latest = str(resp.json()["info"]["version"])
1029
- _write_update_cache(latest)
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 "latest")
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 and target_version == plan.current_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 or 'latest'}")
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"][:_MAX_LINT_ISSUES]:
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"]) > _MAX_LINT_ISSUES:
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"][:_MAX_LINT_ISSUES]:
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
- status = raw_criterion.get("status")
738
- covered_by = raw_criterion.get("covered_by")
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 covered_by
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"]) > _MAX_LINT_ISSUES:
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.2
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.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.47.2"
3
+ version = "1.47.4"
4
4
  description = "Forgexa CLI — command-line client and AI agent runtime for the Forgexa platform"
5
5
  requires-python = ">=3.9"
6
6
  license = "MIT"
@@ -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 == ["/usr/local/bin/pipx", "upgrade", "forgexa-cli"]
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 == ["/usr/local/bin/pipx", "install", "--force", "forgexa-cli==1.12.3"]
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