forgexa-cli 1.62.2__tar.gz → 1.62.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 (29) hide show
  1. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/PKG-INFO +1 -1
  2. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/agent_core.py +63 -3
  4. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/daemon.py +10 -8
  5. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/main.py +77 -60
  6. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/pyproject.toml +1 -1
  8. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_local_bind_commands.py +111 -2
  9. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/README.md +0 -0
  10. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/_build_config.py +0 -0
  11. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/_local_bind.py +0 -0
  12. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/autoupgrade.py +0 -0
  13. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/py.typed +0 -0
  14. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/runtime_evidence.py +0 -0
  15. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/zcode_protocol.py +0 -0
  16. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  17. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  18. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
  19. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/requires.txt +0 -0
  20. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/top_level.txt +0 -0
  21. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/setup.cfg +0 -0
  22. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_auth_and_runtime_commands.py +0 -0
  23. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_autoupgrade.py +0 -0
  24. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_check_command.py +0 -0
  25. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
  26. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.62.2 → forgexa_cli-1.62.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.62.2
3
+ Version: 1.62.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
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.62.2"
2
+ __version__ = "1.62.4"
@@ -3626,7 +3626,7 @@ def is_copilot_model_discovery_auth_error(*messages: str | None) -> bool:
3626
3626
 
3627
3627
 
3628
3628
  def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
3629
- """Return whether the OS refused to create an agent process or thread."""
3629
+ """Return whether the OS refused an agent process, thread, or file watch."""
3630
3630
  detail = " ".join(str(message or "").lower() for message in messages)
3631
3631
  return any(
3632
3632
  pattern in detail
@@ -3635,6 +3635,9 @@ def is_runtime_resource_exhaustion_error(*messages: str | None) -> bool:
3635
3635
  "failed to load ca certificates off thread",
3636
3636
  "resource temporarily unavailable (os error 11)",
3637
3637
  "pthread_create failed",
3638
+ "os file watch limit reached",
3639
+ "inotify watch limit reached",
3640
+ "inotify instance limit reached",
3638
3641
  )
3639
3642
  )
3640
3643
 
@@ -3644,7 +3647,7 @@ def collect_runtime_resource_metrics(
3644
3647
  proc_root: str | Path = "/proc",
3645
3648
  cgroup_root: str | Path = "/sys/fs/cgroup",
3646
3649
  ) -> dict[str, Any]:
3647
- """Collect Linux process/thread headroom without optional dependencies."""
3650
+ """Collect Linux process/thread, memory, and file-watch capacity."""
3648
3651
  metrics: dict[str, Any] = {}
3649
3652
  if not sys.platform.startswith("linux"):
3650
3653
  return metrics
@@ -3714,11 +3717,36 @@ def collect_runtime_resource_metrics(
3714
3717
  break
3715
3718
  except (OSError, ValueError, IndexError):
3716
3719
  pass
3720
+
3721
+ probe_fd: int | None = None
3722
+ try:
3723
+ for name in ("max_user_watches", "max_user_instances"):
3724
+ metrics[f"inotify_{name}"] = int(
3725
+ (proc_path / "sys" / "fs" / "inotify" / name).read_text().strip()
3726
+ )
3727
+ import ctypes
3728
+
3729
+ library = ctypes.CDLL(None, use_errno=True)
3730
+ library.inotify_init1.argtypes = [ctypes.c_int]
3731
+ library.inotify_init1.restype = ctypes.c_int
3732
+ library.inotify_add_watch.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_uint32]
3733
+ library.inotify_add_watch.restype = ctypes.c_int
3734
+ probe_fd = library.inotify_init1(os.O_CLOEXEC | os.O_NONBLOCK)
3735
+ if probe_fd < 0:
3736
+ metrics["inotify_probe_errno"] = ctypes.get_errno()
3737
+ else:
3738
+ watch = library.inotify_add_watch(probe_fd, os.fsencode(proc_path), 0x00000004)
3739
+ metrics["inotify_probe_errno"] = ctypes.get_errno() if watch < 0 else 0
3740
+ except (AttributeError, OSError, ValueError):
3741
+ pass
3742
+ finally:
3743
+ if probe_fd is not None and probe_fd >= 0:
3744
+ os.close(probe_fd)
3717
3745
  return metrics
3718
3746
 
3719
3747
 
3720
3748
  def runtime_resource_exhaustion_reason(metrics: dict[str, Any]) -> str | None:
3721
- """Return a blocking reason when an agent lacks safe process headroom."""
3749
+ """Return a blocking reason when the OS cannot safely start an agent."""
3722
3750
  process_remaining = metrics.get("process_remaining")
3723
3751
  if isinstance(process_remaining, int) and process_remaining < 32:
3724
3752
  return (
@@ -3729,9 +3757,41 @@ def runtime_resource_exhaustion_reason(metrics: dict[str, Any]) -> str | None:
3729
3757
  memory_available_mb = metrics.get("memory_available_mb")
3730
3758
  if isinstance(memory_available_mb, int) and memory_available_mb < 256:
3731
3759
  return f"Only {memory_available_mb} MiB memory is available"
3760
+ import errno
3761
+
3762
+ probe_errno = metrics.get("inotify_probe_errno")
3763
+ if probe_errno == errno.ENOSPC:
3764
+ return (
3765
+ "OS file watch limit reached (inotify ENOSPC; "
3766
+ f"fs.inotify.max_user_watches={metrics.get('inotify_max_user_watches')})"
3767
+ )
3768
+ if probe_errno in {errno.EMFILE, errno.ENFILE}:
3769
+ return (
3770
+ "Inotify instance or file descriptor limit reached "
3771
+ f"({errno.errorcode[probe_errno]}; "
3772
+ f"fs.inotify.max_user_instances={metrics.get('inotify_max_user_instances')})"
3773
+ )
3732
3774
  return None
3733
3775
 
3734
3776
 
3777
+ def runtime_resource_exhaustion_remediation(metrics: dict[str, Any]) -> str:
3778
+ """Suggest recovery for the specific resource rejected by the kernel."""
3779
+ import errno
3780
+
3781
+ probe_errno = metrics.get("inotify_probe_errno")
3782
+ if probe_errno == errno.ENOSPC:
3783
+ return (
3784
+ "Inspect and stop unneeded file watchers owned by the daemon user, "
3785
+ "or raise fs.inotify.max_user_watches, then retry"
3786
+ )
3787
+ if probe_errno in {errno.EMFILE, errno.ENFILE}:
3788
+ return (
3789
+ "Close unused watcher processes or file descriptors; check "
3790
+ "fs.inotify.max_user_instances and the file descriptor limits, then retry"
3791
+ )
3792
+ return "Stop orphaned agent processes or raise the user/cgroup PID limit, then retry"
3793
+
3794
+
3735
3795
  def copilot_auth_state_signature(copilot_home: str | Path) -> str | None:
3736
3796
  """Return a content signature for Copilot's persisted authentication state."""
3737
3797
  config_file = Path(copilot_home) / "config.json"
@@ -1196,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
1196
1196
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1197
1197
  # Kept in sync with pyproject.toml version via bump-version.sh.
1198
1198
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1199
- DAEMON_VERSION = "1.62.2"
1199
+ DAEMON_VERSION = "1.62.4"
1200
1200
 
1201
1201
 
1202
1202
  def _detect_client_type() -> str:
@@ -5919,6 +5919,8 @@ class ProcessManager:
5919
5919
  lower = (text or "").lower()
5920
5920
  if any(p in lower for p in ProcessManager.RATE_LIMIT_PATTERNS):
5921
5921
  return "Agent hit rate/usage limit or quota exhaustion"
5922
+ if agent_core.is_runtime_resource_exhaustion_error(lower):
5923
+ return "Agent host OS resources exhausted"
5922
5924
  if any(p in lower for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS):
5923
5925
  return "Agent backend unavailable or unreachable"
5924
5926
  return None
@@ -6015,11 +6017,13 @@ class ProcessManager:
6015
6017
  "all_agents_rate_limited", "all_agents_unavailable", "acceptance_criteria_uncovered",
6016
6018
  }:
6017
6019
  return None
6018
- if result.failure_code == "agent_platform_unsupported":
6020
+ if result.failure_code in {"agent_platform_unsupported", "runtime_resource_exhausted"}:
6019
6021
  return "agent_unavailable"
6020
6022
  error_text = ProcessManager._failure_pattern_channels(result).lower()
6021
6023
  if any(p in error_text for p in ProcessManager.RATE_LIMIT_PATTERNS):
6022
6024
  return "rate_limit"
6025
+ if agent_core.is_runtime_resource_exhaustion_error(error_text):
6026
+ return "agent_unavailable"
6023
6027
  if any(p in error_text for p in ProcessManager.AGENT_UNAVAILABLE_PATTERNS):
6024
6028
  return "agent_unavailable"
6025
6029
  return None
@@ -6192,9 +6196,8 @@ class ProcessManager:
6192
6196
  "blocking": True,
6193
6197
  "failure_class": "resource_exhausted",
6194
6198
  "detail": resource_reason,
6195
- "remediation": (
6196
- "Stop orphaned agent processes or raise the user/cgroup PID limit, "
6197
- "then retry"
6199
+ "remediation": agent_core.runtime_resource_exhaustion_remediation(
6200
+ resource_metrics,
6198
6201
  ),
6199
6202
  }
6200
6203
  logger.error(
@@ -8741,9 +8744,8 @@ class RuntimePreflightMonitor:
8741
8744
  "blocking": True,
8742
8745
  "failure_class": "resource_exhausted",
8743
8746
  "detail": reason,
8744
- "remediation": (
8745
- "Stop orphaned agent processes or raise the user/cgroup PID limit, "
8746
- "then restart the daemon"
8747
+ "remediation": agent_core.runtime_resource_exhaustion_remediation(
8748
+ metrics,
8747
8749
  ),
8748
8750
  }
8749
8751
  if metrics:
@@ -3266,7 +3266,8 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3266
3266
  )
3267
3267
  break
3268
3268
 
3269
- if binding is None:
3269
+ created_binding = binding is None
3270
+ if created_binding:
3270
3271
  binding = _post(
3271
3272
  f"/projects/{args.project}/local-workspace-bindings",
3272
3273
  {"runtime_id": runtime["id"], "multi_binding_capable": True},
@@ -3275,69 +3276,85 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
3275
3276
  if not binding_id:
3276
3277
  print("Error: failed to create local workspace binding", file=sys.stderr)
3277
3278
  sys.exit(1)
3278
- expected_identity = binding.get("repo_identity")
3279
- if isinstance(expected_identity, str) and expected_identity != identity:
3280
- try:
3281
- _delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3282
- except Exception:
3283
- pass
3284
- print(
3285
- "Error: this folder's Git remote does not match the selected Forgexa project's repository "
3286
- f"(project: {expected_identity}, this folder: {identity}). "
3287
- "Open the matching folder or update the project's repository URL.",
3288
- file=sys.stderr,
3289
- )
3290
- sys.exit(1)
3291
-
3292
- # A folder can only ever be validly bound to one binding_id at a time -
3293
- # drop any other (necessarily stale) entry already pointing at this same
3294
- # path first. Without this, a stale entry left behind by an earlier
3295
- # attempt (e.g. a re-registered runtime after a reinstall, or a
3296
- # verification that timed out client-side) can linger alongside the new
3297
- # one, and agent_host.py's `_match_local_binding_id` (a plain path scan
3298
- # with no tie-breaking) can resolve to the wrong, orphaned binding_id
3299
- # instead of the one just verified. The whole read-modify-write runs
3300
- # under the cross-process mapping lock shared with the Desktop writer.
3301
- with local_workspaces_mapping_lock(local_path):
3302
- mapping = load_local_workspaces_mapping()
3303
- mapping = {
3304
- key: entry
3305
- for key, entry in mapping.items()
3306
- if key == binding_id or entry.get("local_path") != local_path_str
3307
- }
3308
- mapping[binding_id] = {
3309
- "project_id": args.project,
3310
- "user_id": user_id,
3311
- "runtime_id": runtime["id"],
3312
- "repo_identity": identity,
3313
- "local_path": str(local_path),
3314
- "share_agent_output": True,
3315
- }
3316
- save_local_workspaces_mapping(mapping)
3317
-
3318
- _post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
3319
- deadline = time.monotonic() + 35
3320
- while True:
3321
- verified = _get(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3322
- status = verified.get("status", "unknown") if isinstance(verified, dict) else "unknown"
3323
- if status == "active":
3324
- print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
3325
- break
3326
- if status == "stale":
3327
- print(
3328
- f"Local daemon could not verify binding {binding_id[:8]}: "
3329
- f"{verified.get('validation_error') or 'unknown validation failure'}",
3330
- file=sys.stderr,
3331
- )
3332
- sys.exit(1)
3333
- if time.monotonic() >= deadline:
3279
+ try:
3280
+ expected_identity = binding.get("repo_identity")
3281
+ if isinstance(expected_identity, str) and expected_identity != identity:
3334
3282
  print(
3335
- f"Timed out waiting for the daemon to verify binding {binding_id[:8]}. "
3336
- "Restart or update the daemon and try again.",
3283
+ "Error: this folder's Git remote does not match the selected Forgexa project's repository "
3284
+ f"(project: {expected_identity}, this folder: {identity}). "
3285
+ "Open the matching folder or update the project's repository URL.",
3337
3286
  file=sys.stderr,
3338
3287
  )
3339
3288
  sys.exit(1)
3340
- time.sleep(1)
3289
+ if not created_binding and binding.get("status") == "active":
3290
+ print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
3291
+ return
3292
+
3293
+ # A folder can only ever be validly bound to one binding_id at a time -
3294
+ # drop any other (necessarily stale) entry already pointing at this same
3295
+ # path first. Without this, a stale entry left behind by an earlier
3296
+ # attempt (e.g. a re-registered runtime after a reinstall, or a
3297
+ # verification that timed out client-side) can linger alongside the new
3298
+ # one, and agent_host.py's `_match_local_binding_id` (a plain path scan
3299
+ # with no tie-breaking) can resolve to the wrong, orphaned binding_id
3300
+ # instead of the one just verified. The whole read-modify-write runs
3301
+ # under the cross-process mapping lock shared with the Desktop writer.
3302
+ with local_workspaces_mapping_lock(local_path):
3303
+ mapping = load_local_workspaces_mapping()
3304
+ share_agent_output = mapping.get(binding_id, {}).get("share_agent_output") is not False
3305
+ mapping = {
3306
+ key: entry
3307
+ for key, entry in mapping.items()
3308
+ if key == binding_id
3309
+ or Path(str(entry.get("local_path") or "")).expanduser().resolve() != local_path
3310
+ }
3311
+ mapping[binding_id] = {
3312
+ "project_id": args.project,
3313
+ "user_id": user_id,
3314
+ "runtime_id": runtime["id"],
3315
+ "repo_identity": identity,
3316
+ "local_path": str(local_path),
3317
+ "share_agent_output": share_agent_output,
3318
+ }
3319
+ save_local_workspaces_mapping(mapping)
3320
+
3321
+ _post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
3322
+ deadline = time.monotonic() + 35
3323
+ while True:
3324
+ verified = _get(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3325
+ status = verified.get("status", "unknown") if isinstance(verified, dict) else "unknown"
3326
+ if status == "active":
3327
+ print(f"Bound {local_path} to project {args.project} (binding {binding_id[:8]}, status: active)")
3328
+ break
3329
+ if status == "revoked":
3330
+ print(f"Local workspace binding {binding_id[:8]} was revoked; bind this folder again.", file=sys.stderr)
3331
+ sys.exit(1)
3332
+ if status == "stale":
3333
+ print(
3334
+ f"Local daemon could not verify binding {binding_id[:8]}: "
3335
+ f"{verified.get('validation_error') or 'unknown validation failure'}",
3336
+ file=sys.stderr,
3337
+ )
3338
+ sys.exit(1)
3339
+ if time.monotonic() >= deadline:
3340
+ print(
3341
+ f"Timed out waiting for the daemon to verify binding {binding_id[:8]}. "
3342
+ "Restart or update the daemon and try again.",
3343
+ file=sys.stderr,
3344
+ )
3345
+ sys.exit(1)
3346
+ time.sleep(1)
3347
+ except (Exception, SystemExit, KeyboardInterrupt):
3348
+ if created_binding:
3349
+ try:
3350
+ _delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
3351
+ with local_workspaces_mapping_lock(local_path):
3352
+ mapping = load_local_workspaces_mapping()
3353
+ if mapping.pop(binding_id, None) is not None:
3354
+ save_local_workspaces_mapping(mapping)
3355
+ except (Exception, SystemExit):
3356
+ pass
3357
+ raise
3341
3358
 
3342
3359
 
3343
3360
  def cmd_project_unbind_local(args: argparse.Namespace) -> None:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.62.2
3
+ Version: 1.62.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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.62.2"
3
+ version = "1.62.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"
@@ -106,7 +106,10 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
106
106
  }
107
107
 
108
108
 
109
- def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
109
+ @pytest.mark.parametrize("path_alias", [False, True])
110
+ def test_bind_local_replaces_stale_entry_for_same_local_path(
111
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, path_alias: bool,
112
+ ) -> None:
110
113
  """A stale entry from an earlier attempt (e.g. before a daemon/runtime
111
114
  reinstall) must not linger alongside a freshly-verified one for the same
112
115
  folder - agent_host.py's _match_local_binding_id does a plain path scan
@@ -125,7 +128,7 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
125
128
  "user_id": "user-1",
126
129
  "runtime_id": "runtime-old",
127
130
  "repo_identity": "github.com/acme/widgets",
128
- "local_path": str(repo.resolve()),
131
+ "local_path": str(repo.resolve()) + ("/." if path_alias else ""),
129
132
  },
130
133
  }))
131
134
 
@@ -165,6 +168,112 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
165
168
  }
166
169
 
167
170
 
171
+ @pytest.mark.parametrize("stage", ["mapping", "verification", "stale", "revoked", "timeout", "rollback"])
172
+ def test_bind_local_rolls_back_only_new_binding_on_failure(
173
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, stage: str,
174
+ ) -> None:
175
+ from forgexa_cli import _local_bind
176
+
177
+ monkeypatch.setenv("HOME", str(tmp_path))
178
+ repo = tmp_path / "widgets"
179
+ _init_git_repo_with_remote(repo)
180
+ mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
181
+ mapping_path.parent.mkdir()
182
+ original = {"binding-other": {"project_id": "project-1", "local_path": str(tmp_path / "other")}}
183
+ mapping_path.write_text(json.dumps(original))
184
+ monkeypatch.setattr(main, "_get_runtimes", lambda: [{"id": "runtime-1", "pool_type": "personal"}])
185
+ deletes = []
186
+
187
+ def fake_get(path: str):
188
+ if path == "/auth/me":
189
+ return {"id": "user-1"}
190
+ return {"id": "binding-new", "status": stage if stage in ("stale", "revoked") else "pending"}
191
+
192
+ def fake_post(path: str, data=None):
193
+ if path.endswith("/local-workspace-bindings"):
194
+ return {"id": "binding-new", "status": "pending", "repo_identity": "github.com/acme/widgets"}
195
+ if stage in ("verification", "rollback"):
196
+ raise RuntimeError("Verification unavailable")
197
+ return {"status": "pending"}
198
+
199
+ def fake_delete(path: str):
200
+ deletes.append(path)
201
+ if stage == "rollback":
202
+ raise SystemExit(1)
203
+
204
+ monkeypatch.setattr(main, "_get", fake_get)
205
+ monkeypatch.setattr(main, "_post", fake_post)
206
+ monkeypatch.setattr(main, "_delete", fake_delete)
207
+ clock = iter([0, 36])
208
+ monkeypatch.setattr(main, "time", SimpleNamespace(
209
+ monotonic=lambda: next(clock, 36),
210
+ sleep=lambda duration: pytest.fail("A terminal failure must not keep polling"),
211
+ ))
212
+ if stage == "mapping":
213
+ def fail_save(mapping):
214
+ raise RuntimeError("Mapping unavailable")
215
+ monkeypatch.setattr(_local_bind, "save_local_workspaces_mapping", fail_save)
216
+
217
+ args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
218
+ with pytest.raises((SystemExit, RuntimeError)):
219
+ main.cmd_project_bind_local(args)
220
+
221
+ assert deletes == ["/projects/project-1/local-workspace-bindings/binding-new"]
222
+ mapping = json.loads(mapping_path.read_text())
223
+ assert mapping["binding-other"] == original["binding-other"]
224
+ assert ("binding-new" in mapping) == (stage == "rollback")
225
+
226
+
227
+ @pytest.mark.parametrize("status", ["active", "stale", "repository_mismatch"])
228
+ def test_bind_local_preserves_existing_binding_and_output_sharing(
229
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, status: str,
230
+ ) -> None:
231
+ monkeypatch.setenv("HOME", str(tmp_path))
232
+ repo = tmp_path / "widgets"
233
+ _init_git_repo_with_remote(repo)
234
+ mapping_path = tmp_path / ".forgexa" / "local-workspaces.json"
235
+ mapping_path.parent.mkdir()
236
+ entry = {
237
+ "project_id": "project-1", "user_id": "user-1", "runtime_id": "runtime-1",
238
+ "repo_identity": "github.com/acme/widgets", "local_path": str(repo),
239
+ "share_agent_output": False,
240
+ }
241
+ mapping_path.write_text(json.dumps({"binding-existing": entry}))
242
+ monkeypatch.setattr(main, "_get_runtimes", lambda: [{"id": "runtime-1", "pool_type": "personal"}])
243
+ posts = []
244
+ deletes = []
245
+
246
+ def fake_get(path: str):
247
+ if path == "/auth/me":
248
+ return {"id": "user-1"}
249
+ if path.endswith("/local-workspace-bindings"):
250
+ return [{
251
+ "id": "binding-existing", "status": "active" if status == "repository_mismatch" else status,
252
+ "repo_identity": "github.com/acme/other" if status == "repository_mismatch" else "github.com/acme/widgets",
253
+ "lease_held": status == "active",
254
+ }]
255
+ return {"id": "binding-existing", "status": "active"}
256
+
257
+ def fake_post(path: str, data=None):
258
+ posts.append(path)
259
+ assert path.endswith("/verify")
260
+ return {"status": "pending"}
261
+
262
+ monkeypatch.setattr(main, "_get", fake_get)
263
+ monkeypatch.setattr(main, "_post", fake_post)
264
+ monkeypatch.setattr(main, "_delete", lambda path: deletes.append(path))
265
+ args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
266
+ if status == "repository_mismatch":
267
+ with pytest.raises(SystemExit):
268
+ main.cmd_project_bind_local(args)
269
+ else:
270
+ main.cmd_project_bind_local(args)
271
+
272
+ assert posts == (["/projects/project-1/local-workspace-bindings/binding-existing/verify"] if status == "stale" else [])
273
+ assert deletes == []
274
+ assert json.loads(mapping_path.read_text()) == {"binding-existing": entry}
275
+
276
+
168
277
  def test_bind_local_rejects_non_git_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
169
278
  monkeypatch.setenv("HOME", str(tmp_path))
170
279
  plain_dir = tmp_path / "not-a-repo"
File without changes
File without changes