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.
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/PKG-INFO +1 -1
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/agent_core.py +63 -3
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/daemon.py +10 -8
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/main.py +77 -60
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/pyproject.toml +1 -1
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_local_bind_commands.py +111 -2
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/README.md +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/setup.cfg +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.62.2 → forgexa_cli-1.62.4}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.62.
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3279
|
-
|
|
3280
|
-
|
|
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
|
-
|
|
3336
|
-
"
|
|
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
|
-
|
|
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:
|
|
@@ -106,7 +106,10 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
106
106
|
}
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
|
|
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
|
|
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
|