forgexa-cli 1.59.0__tar.gz → 1.59.2__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.59.0 → forgexa_cli-1.59.2}/PKG-INFO +1 -1
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/_local_bind.py +22 -46
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/agent_core.py +41 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/daemon.py +62 -8
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/main.py +2 -2
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/pyproject.toml +1 -1
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_local_bind_commands.py +40 -3
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/README.md +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/setup.cfg +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.59.0 → forgexa_cli-1.59.2}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.59.
|
|
2
|
+
__version__ = "1.59.2"
|
|
@@ -47,58 +47,34 @@ def save_local_workspaces_mapping(mapping: dict) -> None:
|
|
|
47
47
|
|
|
48
48
|
|
|
49
49
|
@contextlib.contextmanager
|
|
50
|
-
def local_workspaces_mapping_lock() -> Iterator[None]:
|
|
51
|
-
"""
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
crate); without it, a CLI bind-local and a Desktop link running
|
|
55
|
-
concurrently could each drop the other's just-written entry. Best-effort:
|
|
56
|
-
platforms without fcntl/msvcrt degrade to lock-free (the atomic rename
|
|
57
|
-
still prevents torn files).
|
|
58
|
-
"""
|
|
50
|
+
def local_workspaces_mapping_lock(workspace_path: str | Path | None = None) -> Iterator[None]:
|
|
51
|
+
"""Serialize mapping writes; lock or parse failures never degrade to unlocked writes."""
|
|
52
|
+
from forgexa_cli.agent_core import acquire_file_lock, release_file_lock
|
|
53
|
+
|
|
59
54
|
path = local_workspaces_mapping_path()
|
|
60
55
|
lock_path = path.with_name(path.name + ".lock")
|
|
56
|
+
lock_file = acquire_file_lock(lock_path, blocking=True)
|
|
57
|
+
if lock_file is None:
|
|
58
|
+
raise RuntimeError("Could not acquire the local workspace mapping write lock")
|
|
59
|
+
workspace_lock = None
|
|
61
60
|
try:
|
|
62
|
-
path.
|
|
63
|
-
lock_file = open(lock_path, "a+")
|
|
64
|
-
except OSError:
|
|
65
|
-
yield
|
|
66
|
-
return
|
|
67
|
-
|
|
68
|
-
lock_kind = None
|
|
69
|
-
try:
|
|
70
|
-
try:
|
|
71
|
-
import fcntl
|
|
72
|
-
|
|
73
|
-
fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX)
|
|
74
|
-
lock_kind = "fcntl"
|
|
75
|
-
except ImportError:
|
|
61
|
+
if path.exists():
|
|
76
62
|
try:
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
63
|
+
mapping = json.loads(path.read_text(encoding="utf-8"))
|
|
64
|
+
if not isinstance(mapping, dict):
|
|
65
|
+
raise ValueError("Mapping must be a JSON object")
|
|
66
|
+
except (OSError, ValueError) as error:
|
|
67
|
+
raise RuntimeError("Local workspace mapping is unreadable; recover it before modifying bindings") from error
|
|
68
|
+
if workspace_path is not None:
|
|
69
|
+
from forgexa_cli.agent_core import acquire_workspace_write_lock
|
|
70
|
+
|
|
71
|
+
workspace_lock = acquire_workspace_write_lock(workspace_path)
|
|
72
|
+
if workspace_lock is None:
|
|
73
|
+
raise RuntimeError("A task or agent is writing this workspace; wait before changing its mapping")
|
|
84
74
|
yield
|
|
85
75
|
finally:
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
import fcntl
|
|
89
|
-
|
|
90
|
-
fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN)
|
|
91
|
-
except (ImportError, OSError):
|
|
92
|
-
pass
|
|
93
|
-
elif lock_kind == "msvcrt":
|
|
94
|
-
try:
|
|
95
|
-
import msvcrt
|
|
96
|
-
|
|
97
|
-
lock_file.seek(0)
|
|
98
|
-
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
99
|
-
except (ImportError, OSError):
|
|
100
|
-
pass
|
|
101
|
-
lock_file.close()
|
|
76
|
+
release_file_lock(workspace_lock)
|
|
77
|
+
release_file_lock(lock_file)
|
|
102
78
|
|
|
103
79
|
|
|
104
80
|
_SSH_SCHEME_RE = re.compile(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
|
|
@@ -1125,8 +1125,49 @@ def acquire_file_lock(
|
|
|
1125
1125
|
return lock_file
|
|
1126
1126
|
|
|
1127
1127
|
|
|
1128
|
+
def workspace_write_lock_path(workspace_path: str | Path) -> Path:
|
|
1129
|
+
"""Resolve one physical worktree's shared lock without uploading its path."""
|
|
1130
|
+
root = Path(workspace_path).expanduser().resolve()
|
|
1131
|
+
try:
|
|
1132
|
+
result = subprocess.run(
|
|
1133
|
+
["git", "rev-parse", "--absolute-git-dir"],
|
|
1134
|
+
cwd=str(root), capture_output=True, text=True, timeout=10,
|
|
1135
|
+
)
|
|
1136
|
+
if result.returncode == 0 and result.stdout.strip():
|
|
1137
|
+
return Path(result.stdout.strip()).resolve() / "forgexa-workspace.lock"
|
|
1138
|
+
if (root / ".git").exists():
|
|
1139
|
+
raise RuntimeError("Could not resolve the local workspace Git metadata")
|
|
1140
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
1141
|
+
if (root / ".git").exists():
|
|
1142
|
+
raise RuntimeError("Could not resolve the local workspace Git metadata")
|
|
1143
|
+
key = hashlib.sha256(str(root).encode("utf-8")).hexdigest()
|
|
1144
|
+
return Path.home() / ".forgexa" / "locks" / "workspaces" / f"{key}.lock"
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def acquire_workspace_write_lock(
|
|
1148
|
+
workspace_path: str | Path, *, companion_lock_path: str | Path | None = None,
|
|
1149
|
+
):
|
|
1150
|
+
"""Acquire a physical-directory lock and an optional legacy identity lock."""
|
|
1151
|
+
companion = acquire_file_lock(companion_lock_path) if companion_lock_path is not None else None
|
|
1152
|
+
if companion_lock_path is not None and companion is None:
|
|
1153
|
+
return None
|
|
1154
|
+
try:
|
|
1155
|
+
physical = acquire_file_lock(workspace_write_lock_path(workspace_path))
|
|
1156
|
+
except (OSError, RuntimeError):
|
|
1157
|
+
release_file_lock(companion)
|
|
1158
|
+
return None
|
|
1159
|
+
if physical is None:
|
|
1160
|
+
release_file_lock(companion)
|
|
1161
|
+
return None
|
|
1162
|
+
return (companion, physical) if companion is not None else physical
|
|
1163
|
+
|
|
1164
|
+
|
|
1128
1165
|
def release_file_lock(lock_file) -> None:
|
|
1129
1166
|
"""Release a handle returned by :func:`acquire_file_lock` (None-safe)."""
|
|
1167
|
+
if isinstance(lock_file, tuple):
|
|
1168
|
+
for handle in reversed(lock_file):
|
|
1169
|
+
release_file_lock(handle)
|
|
1170
|
+
return
|
|
1130
1171
|
if lock_file is None:
|
|
1131
1172
|
return
|
|
1132
1173
|
try:
|
|
@@ -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.59.
|
|
1199
|
+
DAEMON_VERSION = "1.59.2"
|
|
1200
1200
|
|
|
1201
1201
|
|
|
1202
1202
|
def _detect_client_type() -> str:
|
|
@@ -2797,7 +2797,7 @@ def _is_sensitive_local_path(path: str) -> bool:
|
|
|
2797
2797
|
return any(pattern.search(normalized) for pattern in _LOCAL_BIND_SENSITIVE_PATH_PATTERNS)
|
|
2798
2798
|
|
|
2799
2799
|
|
|
2800
|
-
def _try_acquire_local_workspace_file_lock(binding_id: str):
|
|
2800
|
+
def _try_acquire_local_workspace_file_lock(binding_id: str, workspace_path: Path | None = None):
|
|
2801
2801
|
"""Non-blocking OS-level file lock keyed by binding_id.
|
|
2802
2802
|
|
|
2803
2803
|
See docs/designs/forgexa-ide-design.md §6.3: the DB-side
|
|
@@ -2814,6 +2814,14 @@ def _try_acquire_local_workspace_file_lock(binding_id: str):
|
|
|
2814
2814
|
agent_core.acquire_file_lock so the IDE agent_host sidecar shares them.
|
|
2815
2815
|
"""
|
|
2816
2816
|
lock_dir = Path.home() / ".forgexa" / "locks"
|
|
2817
|
+
if workspace_path is None:
|
|
2818
|
+
entry = _load_local_workspaces_mapping().get(binding_id)
|
|
2819
|
+
if isinstance(entry, dict) and isinstance(entry.get("local_path"), str) and entry["local_path"]:
|
|
2820
|
+
workspace_path = Path(entry["local_path"]).expanduser()
|
|
2821
|
+
if workspace_path is not None:
|
|
2822
|
+
return agent_core.acquire_workspace_write_lock(
|
|
2823
|
+
workspace_path, companion_lock_path=lock_dir / f"{binding_id}.lock",
|
|
2824
|
+
)
|
|
2817
2825
|
return agent_core.acquire_file_lock(lock_dir / f"{binding_id}.lock")
|
|
2818
2826
|
|
|
2819
2827
|
|
|
@@ -8543,6 +8551,8 @@ class ProgressReporter:
|
|
|
8543
8551
|
timeout=10,
|
|
8544
8552
|
)
|
|
8545
8553
|
if 200 <= response.status_code < 300:
|
|
8554
|
+
if response.json().get("status") == "ignored":
|
|
8555
|
+
return False
|
|
8546
8556
|
self._pending_output_lines.pop(task_id, None)
|
|
8547
8557
|
self._last_progress_sent[task_id] = {
|
|
8548
8558
|
"progress_percent": progress_percent,
|
|
@@ -12120,7 +12130,17 @@ class RuntimeDaemon:
|
|
|
12120
12130
|
return
|
|
12121
12131
|
|
|
12122
12132
|
# 2. Prepare workspace
|
|
12123
|
-
await reporter.report_progress(
|
|
12133
|
+
authorized = await reporter.report_progress(
|
|
12134
|
+
task.task_id, 5, "preparing_workspace", execution_attempt_id=task.execution_attempt_id,
|
|
12135
|
+
)
|
|
12136
|
+
if is_local_bind and not authorized:
|
|
12137
|
+
await report_task_complete(TaskResult(
|
|
12138
|
+
status="failed", exit_code=-1, stdout="", stderr="",
|
|
12139
|
+
error="Local execution authorization is no longer current or could not be verified",
|
|
12140
|
+
failure_code="local_binding_unavailable",
|
|
12141
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12142
|
+
))
|
|
12143
|
+
return
|
|
12124
12144
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
12125
12145
|
task.project, task, runtime_id=conn.runtime_id,
|
|
12126
12146
|
)
|
|
@@ -12782,11 +12802,18 @@ class RuntimeDaemon:
|
|
|
12782
12802
|
# directory: never `git add -A` sensitive paths, never
|
|
12783
12803
|
# rebase/merge/reset, fast-forward push only. See
|
|
12784
12804
|
# docs/designs/forgexa-ide-design.md §6.2 item 6.
|
|
12785
|
-
|
|
12786
|
-
|
|
12787
|
-
task,
|
|
12788
|
-
before_sha=node_before_sha,
|
|
12805
|
+
authorized = await reporter.report_progress(
|
|
12806
|
+
task.task_id, 95, "verifying_local_commit_authorization",
|
|
12807
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12789
12808
|
)
|
|
12809
|
+
if not authorized:
|
|
12810
|
+
commit_result = {"commit_error": "Local execution authorization changed; preserving work without committing or pushing"}
|
|
12811
|
+
else:
|
|
12812
|
+
commit_result = await self.local_commit_and_push(
|
|
12813
|
+
workspace_path,
|
|
12814
|
+
task,
|
|
12815
|
+
before_sha=node_before_sha,
|
|
12816
|
+
)
|
|
12790
12817
|
else:
|
|
12791
12818
|
commit_result = await self._auto_commit(
|
|
12792
12819
|
workspace_path,
|
|
@@ -15811,6 +15838,17 @@ class RuntimeDaemon:
|
|
|
15811
15838
|
"then retry."
|
|
15812
15839
|
)}
|
|
15813
15840
|
|
|
15841
|
+
if before_sha:
|
|
15842
|
+
try:
|
|
15843
|
+
await git(
|
|
15844
|
+
"merge-base", "--is-ancestor", before_sha, "HEAD", cwd=workspace_path,
|
|
15845
|
+
)
|
|
15846
|
+
except RuntimeError:
|
|
15847
|
+
return {"commit_error": (
|
|
15848
|
+
"Refusing to commit: the task baseline is no longer reachable "
|
|
15849
|
+
"from HEAD. Review the branch history yourself before retrying."
|
|
15850
|
+
)}
|
|
15851
|
+
|
|
15814
15852
|
await self._discard_transient_untracked_runtime_files(workspace_path)
|
|
15815
15853
|
status_output = await git("status", "--porcelain", cwd=workspace_path)
|
|
15816
15854
|
dirty_paths = [line[3:] for line in status_output.splitlines() if line.strip()]
|
|
@@ -15856,6 +15894,16 @@ class RuntimeDaemon:
|
|
|
15856
15894
|
logger.warning("Failed to build rich commit message: %s — using fallback", msg_err)
|
|
15857
15895
|
commit_msg = f"{task.node_type}({task.requirement_key or task.task_id}): {display_title}"
|
|
15858
15896
|
|
|
15897
|
+
commit_branch = (
|
|
15898
|
+
await git("rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path)
|
|
15899
|
+
).strip()
|
|
15900
|
+
if commit_branch != target_branch:
|
|
15901
|
+
return {"commit_error": "Refusing to commit: the task branch changed while preparing the commit"}
|
|
15902
|
+
if before_sha:
|
|
15903
|
+
try:
|
|
15904
|
+
await git("merge-base", "--is-ancestor", before_sha, "HEAD", cwd=workspace_path)
|
|
15905
|
+
except RuntimeError:
|
|
15906
|
+
return {"commit_error": "Refusing to commit: the task baseline changed while preparing the commit"}
|
|
15859
15907
|
_git_name, _git_email = _resolve_git_author(task.project)
|
|
15860
15908
|
proc = await asyncio.create_subprocess_exec(
|
|
15861
15909
|
"git", "commit", "-m", commit_msg,
|
|
@@ -15938,9 +15986,15 @@ class RuntimeDaemon:
|
|
|
15938
15986
|
f"'{target_branch}' and retry."
|
|
15939
15987
|
)}
|
|
15940
15988
|
|
|
15989
|
+
verified_head = (await git("rev-parse", "HEAD", cwd=workspace_path)).strip()
|
|
15990
|
+
if before_sha:
|
|
15991
|
+
try:
|
|
15992
|
+
await git("merge-base", "--is-ancestor", before_sha, verified_head, cwd=workspace_path)
|
|
15993
|
+
except RuntimeError:
|
|
15994
|
+
return {"push_error": "Refusing to push: the task baseline is no longer reachable"}
|
|
15941
15995
|
try:
|
|
15942
15996
|
await git(
|
|
15943
|
-
"push", "origin",
|
|
15997
|
+
"push", "origin", f"{verified_head}:refs/heads/{target_branch}",
|
|
15944
15998
|
cwd=workspace_path, timeout=settings.GIT_PUSH_TIMEOUT,
|
|
15945
15999
|
)
|
|
15946
16000
|
except RuntimeError as exc:
|
|
@@ -3297,7 +3297,7 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3297
3297
|
# with no tie-breaking) can resolve to the wrong, orphaned binding_id
|
|
3298
3298
|
# instead of the one just verified. The whole read-modify-write runs
|
|
3299
3299
|
# under the cross-process mapping lock shared with the Desktop writer.
|
|
3300
|
-
with local_workspaces_mapping_lock():
|
|
3300
|
+
with local_workspaces_mapping_lock(local_path):
|
|
3301
3301
|
mapping = load_local_workspaces_mapping()
|
|
3302
3302
|
mapping = {
|
|
3303
3303
|
key: entry
|
|
@@ -3370,7 +3370,7 @@ def cmd_project_unbind_local(args: argparse.Namespace) -> None:
|
|
|
3370
3370
|
_delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
3371
3371
|
from forgexa_cli._local_bind import local_workspaces_mapping_lock
|
|
3372
3372
|
|
|
3373
|
-
with local_workspaces_mapping_lock(): # re-read under the cross-process lock
|
|
3373
|
+
with local_workspaces_mapping_lock(entry.get("local_path")): # re-read under the cross-process lock
|
|
3374
3374
|
mapping = load_local_workspaces_mapping()
|
|
3375
3375
|
mapping.pop(binding_id, None)
|
|
3376
3376
|
save_local_workspaces_mapping(mapping)
|
|
@@ -24,6 +24,41 @@ def _init_git_repo_with_remote(path: Path, remote_url: str = "https://github.com
|
|
|
24
24
|
subprocess.run(["git", "remote", "add", "origin", remote_url], cwd=path, check=True)
|
|
25
25
|
|
|
26
26
|
|
|
27
|
+
def test_mapping_write_lock_rejects_corrupt_mapping(tmp_path, monkeypatch):
|
|
28
|
+
from forgexa_cli._local_bind import local_workspaces_mapping_lock
|
|
29
|
+
|
|
30
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
31
|
+
path = tmp_path / ".forgexa" / "local-workspaces.json"
|
|
32
|
+
path.parent.mkdir()
|
|
33
|
+
path.write_text("not valid json", encoding="utf-8")
|
|
34
|
+
|
|
35
|
+
with pytest.raises(RuntimeError):
|
|
36
|
+
with local_workspaces_mapping_lock():
|
|
37
|
+
pytest.fail("Corrupt mapping must not enter a write transaction")
|
|
38
|
+
assert path.read_text(encoding="utf-8") == "not valid json"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_mapping_other_workspace_remains_mutable_while_one_is_busy(tmp_path, monkeypatch):
|
|
42
|
+
from forgexa_cli._local_bind import local_workspaces_mapping_lock
|
|
43
|
+
from forgexa_cli.agent_core import acquire_workspace_write_lock, release_file_lock
|
|
44
|
+
|
|
45
|
+
monkeypatch.setenv("HOME", str(tmp_path))
|
|
46
|
+
busy_repo = tmp_path / "busy"
|
|
47
|
+
free_repo = tmp_path / "free"
|
|
48
|
+
_init_git_repo_with_remote(busy_repo)
|
|
49
|
+
_init_git_repo_with_remote(free_repo)
|
|
50
|
+
path = tmp_path / ".forgexa" / "local-workspaces.json"
|
|
51
|
+
path.parent.mkdir()
|
|
52
|
+
path.write_text(json.dumps({"old": {"local_path": str(busy_repo)}}), encoding="utf-8")
|
|
53
|
+
writer = acquire_workspace_write_lock(busy_repo)
|
|
54
|
+
try:
|
|
55
|
+
assert writer is not None
|
|
56
|
+
with local_workspaces_mapping_lock(free_repo):
|
|
57
|
+
assert path.exists()
|
|
58
|
+
finally:
|
|
59
|
+
release_file_lock(writer)
|
|
60
|
+
|
|
61
|
+
|
|
27
62
|
def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
28
63
|
monkeypatch.setenv("HOME", str(tmp_path))
|
|
29
64
|
repo = tmp_path / "my-real-project"
|
|
@@ -55,7 +90,7 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
55
90
|
args = SimpleNamespace(project="project-1", path=str(repo), runtime=None)
|
|
56
91
|
main.cmd_project_bind_local(args)
|
|
57
92
|
|
|
58
|
-
assert posts[0] == ("/projects/project-1/local-workspace-bindings", {"runtime_id": "runtime-1"})
|
|
93
|
+
assert posts[0] == ("/projects/project-1/local-workspace-bindings", {"runtime_id": "runtime-1", "multi_binding_capable": True})
|
|
59
94
|
assert posts[1] == ("/projects/project-1/local-workspace-bindings/binding-1/verify", {})
|
|
60
95
|
|
|
61
96
|
mapping = json.loads((tmp_path / ".forgexa" / "local-workspaces.json").read_text())
|
|
@@ -66,6 +101,7 @@ def test_bind_local_happy_path(tmp_path: Path, monkeypatch: pytest.MonkeyPatch)
|
|
|
66
101
|
"runtime_id": "runtime-1",
|
|
67
102
|
"repo_identity": "github.com/acme/widgets",
|
|
68
103
|
"local_path": str(repo.resolve()),
|
|
104
|
+
"share_agent_output": True,
|
|
69
105
|
},
|
|
70
106
|
}
|
|
71
107
|
|
|
@@ -124,6 +160,7 @@ def test_bind_local_replaces_stale_entry_for_same_local_path(tmp_path: Path, mon
|
|
|
124
160
|
"runtime_id": "runtime-new",
|
|
125
161
|
"repo_identity": "github.com/acme/widgets",
|
|
126
162
|
"local_path": str(repo.resolve()),
|
|
163
|
+
"share_agent_output": True,
|
|
127
164
|
},
|
|
128
165
|
}
|
|
129
166
|
|
|
@@ -184,7 +221,7 @@ def test_bind_local_rejects_project_repository_mismatch_before_writing_mapping(
|
|
|
184
221
|
main.cmd_project_bind_local(args)
|
|
185
222
|
|
|
186
223
|
assert posts == [
|
|
187
|
-
("/projects/project-1/local-workspace-bindings", {"runtime_id": "runtime-1"}),
|
|
224
|
+
("/projects/project-1/local-workspace-bindings", {"runtime_id": "runtime-1", "multi_binding_capable": True}),
|
|
188
225
|
]
|
|
189
226
|
assert deletes == ["/projects/project-1/local-workspace-bindings/binding-1"]
|
|
190
227
|
assert not (tmp_path / ".forgexa" / "local-workspaces.json").exists()
|
|
@@ -228,7 +265,7 @@ def test_bind_local_uses_runtime_prefix_to_disambiguate(
|
|
|
228
265
|
|
|
229
266
|
def fake_post(path: str, data=None):
|
|
230
267
|
if path.endswith("/local-workspace-bindings"):
|
|
231
|
-
assert data == {"runtime_id": "runtime-2"}
|
|
268
|
+
assert data == {"runtime_id": "runtime-2", "multi_binding_capable": True}
|
|
232
269
|
return {"id": "binding-1", "status": "pending"}
|
|
233
270
|
if path.endswith("/verify"):
|
|
234
271
|
return {"id": "binding-1", "status": "pending"}
|
|
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
|