forgexa-cli 1.58.3__tar.gz → 1.59.1__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.58.3 → forgexa_cli-1.59.1}/PKG-INFO +1 -1
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/_local_bind.py +57 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/daemon.py +49 -10
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/main.py +99 -24
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/pyproject.toml +1 -1
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/README.md +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/setup.cfg +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.58.3 → forgexa_cli-1.59.1}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.59.1"
|
|
@@ -11,11 +11,13 @@ changes. See docs/designs/forgexa-ide-design.md §6.1/§6.2/§6.4.
|
|
|
11
11
|
"""
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import contextlib
|
|
14
15
|
import json
|
|
15
16
|
import os
|
|
16
17
|
import re
|
|
17
18
|
import subprocess
|
|
18
19
|
from pathlib import Path
|
|
20
|
+
from typing import Iterator
|
|
19
21
|
|
|
20
22
|
|
|
21
23
|
def local_workspaces_mapping_path() -> Path:
|
|
@@ -44,6 +46,61 @@ def save_local_workspaces_mapping(mapping: dict) -> None:
|
|
|
44
46
|
tmp_path.replace(path)
|
|
45
47
|
|
|
46
48
|
|
|
49
|
+
@contextlib.contextmanager
|
|
50
|
+
def local_workspaces_mapping_lock() -> Iterator[None]:
|
|
51
|
+
"""Advisory cross-process lock for read-modify-write cycles on the
|
|
52
|
+
mapping file (multi-workspace parallel design §6.1/§6.3). Shares the
|
|
53
|
+
`local-workspaces.json.lock` path with the Desktop Rust writer (fd-lock
|
|
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
|
+
"""
|
|
59
|
+
path = local_workspaces_mapping_path()
|
|
60
|
+
lock_path = path.with_name(path.name + ".lock")
|
|
61
|
+
try:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
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:
|
|
76
|
+
try:
|
|
77
|
+
import msvcrt
|
|
78
|
+
|
|
79
|
+
lock_file.seek(0)
|
|
80
|
+
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
|
|
81
|
+
lock_kind = "msvcrt"
|
|
82
|
+
except (ImportError, OSError):
|
|
83
|
+
pass
|
|
84
|
+
yield
|
|
85
|
+
finally:
|
|
86
|
+
if lock_kind == "fcntl":
|
|
87
|
+
try:
|
|
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()
|
|
102
|
+
|
|
103
|
+
|
|
47
104
|
_SSH_SCHEME_RE = re.compile(r"^ssh://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
|
|
48
105
|
_HTTP_SCHEME_RE = re.compile(r"^https?://(?:[^@/]+@)?([^/:]+)(?::\d+)?/(.+?)(?:\.git)?/?$")
|
|
49
106
|
_SCP_LIKE_RE = re.compile(r"^[^@/]+@([^:/]+):(.+?)(?:\.git)?/?$")
|
|
@@ -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.
|
|
1199
|
+
DAEMON_VERSION = "1.59.1"
|
|
1200
1200
|
|
|
1201
1201
|
|
|
1202
1202
|
def _detect_client_type() -> str:
|
|
@@ -8507,6 +8507,7 @@ class ProgressReporter:
|
|
|
8507
8507
|
files_changed: list[str] | None = None,
|
|
8508
8508
|
lines_added: int = 0,
|
|
8509
8509
|
lines_removed: int = 0,
|
|
8510
|
+
execution_attempt_id: str | None = None,
|
|
8510
8511
|
) -> bool:
|
|
8511
8512
|
output_lines = [
|
|
8512
8513
|
*self._pending_output_lines.get(task_id, []),
|
|
@@ -8537,6 +8538,7 @@ class ProgressReporter:
|
|
|
8537
8538
|
"files_changed": files_changed,
|
|
8538
8539
|
"lines_added": lines_added,
|
|
8539
8540
|
"lines_removed": lines_removed,
|
|
8541
|
+
"execution_attempt_id": execution_attempt_id,
|
|
8540
8542
|
},
|
|
8541
8543
|
timeout=10,
|
|
8542
8544
|
)
|
|
@@ -9017,7 +9019,7 @@ class HeartbeatService:
|
|
|
9017
9019
|
"active_tasks": self._active_tasks,
|
|
9018
9020
|
"available_agents": self._agents,
|
|
9019
9021
|
"capabilities": {
|
|
9020
|
-
"local_workspace_bindings": {"protocol_version":
|
|
9022
|
+
"local_workspace_bindings": {"protocol_version": 4},
|
|
9021
9023
|
"local_commit_and_push": {"protocol_version": 1},
|
|
9022
9024
|
"local_fresh_branch_reset": {
|
|
9023
9025
|
"protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
|
|
@@ -9387,7 +9389,7 @@ class ServerConnection:
|
|
|
9387
9389
|
"capabilities": {
|
|
9388
9390
|
"platform": platform.platform(),
|
|
9389
9391
|
"python_version": platform.python_version(),
|
|
9390
|
-
"local_workspace_bindings": {"protocol_version":
|
|
9392
|
+
"local_workspace_bindings": {"protocol_version": 4},
|
|
9391
9393
|
"local_commit_and_push": {"protocol_version": 1},
|
|
9392
9394
|
"local_fresh_branch_reset": {
|
|
9393
9395
|
"protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
|
|
@@ -9807,7 +9809,7 @@ class ServerConnection:
|
|
|
9807
9809
|
"capabilities": {
|
|
9808
9810
|
"platform": platform.platform(),
|
|
9809
9811
|
"python_version": platform.python_version(),
|
|
9810
|
-
"local_workspace_bindings": {"protocol_version":
|
|
9812
|
+
"local_workspace_bindings": {"protocol_version": 4},
|
|
9811
9813
|
"local_commit_and_push": {"protocol_version": 1},
|
|
9812
9814
|
"local_fresh_branch_reset": {
|
|
9813
9815
|
"protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
|
|
@@ -12023,7 +12025,7 @@ class RuntimeDaemon:
|
|
|
12023
12025
|
task.task_id,
|
|
12024
12026
|
project_lock_key,
|
|
12025
12027
|
)
|
|
12026
|
-
await reporter.report_progress(task.task_id, 1, "waiting_for_execution_lock")
|
|
12028
|
+
await reporter.report_progress(task.task_id, 1, "waiting_for_execution_lock", execution_attempt_id=task.execution_attempt_id)
|
|
12027
12029
|
await project_lock.acquire()
|
|
12028
12030
|
workspace_execution_lock: _WorkspaceFileLock | None = None
|
|
12029
12031
|
local_bind_file_lock = None
|
|
@@ -12083,7 +12085,7 @@ class RuntimeDaemon:
|
|
|
12083
12085
|
))
|
|
12084
12086
|
return
|
|
12085
12087
|
|
|
12086
|
-
await reporter.report_progress(task.task_id, 3, "waiting_for_workspace_lock")
|
|
12088
|
+
await reporter.report_progress(task.task_id, 3, "waiting_for_workspace_lock", execution_attempt_id=task.execution_attempt_id)
|
|
12087
12089
|
workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
|
|
12088
12090
|
task.project,
|
|
12089
12091
|
task,
|
|
@@ -12118,7 +12120,7 @@ class RuntimeDaemon:
|
|
|
12118
12120
|
return
|
|
12119
12121
|
|
|
12120
12122
|
# 2. Prepare workspace
|
|
12121
|
-
await reporter.report_progress(task.task_id, 5, "preparing_workspace")
|
|
12123
|
+
await reporter.report_progress(task.task_id, 5, "preparing_workspace", execution_attempt_id=task.execution_attempt_id)
|
|
12122
12124
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
12123
12125
|
task.project, task, runtime_id=conn.runtime_id,
|
|
12124
12126
|
)
|
|
@@ -12137,6 +12139,7 @@ class RuntimeDaemon:
|
|
|
12137
12139
|
8,
|
|
12138
12140
|
"preparing_attachments",
|
|
12139
12141
|
output_lines=["[daemon] Preparing task attachments"],
|
|
12142
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12140
12143
|
)
|
|
12141
12144
|
attachment_staging_dir = await self._stage_task_attachments(task, conn)
|
|
12142
12145
|
except _TaskAttachmentDownloadError as exc:
|
|
@@ -12146,6 +12149,7 @@ class RuntimeDaemon:
|
|
|
12146
12149
|
8,
|
|
12147
12150
|
"attachment_download_failed",
|
|
12148
12151
|
output_lines=["[daemon] Required task attachment could not be downloaded"],
|
|
12152
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12149
12153
|
)
|
|
12150
12154
|
await report_task_complete(TaskResult(
|
|
12151
12155
|
status="failed",
|
|
@@ -12186,7 +12190,7 @@ class RuntimeDaemon:
|
|
|
12186
12190
|
)
|
|
12187
12191
|
|
|
12188
12192
|
# 3. Run agent with real-time output streaming + periodic progress heartbeat
|
|
12189
|
-
await reporter.report_progress(task.task_id, 10, "running_agent")
|
|
12193
|
+
await reporter.report_progress(task.task_id, 10, "running_agent", execution_attempt_id=task.execution_attempt_id)
|
|
12190
12194
|
|
|
12191
12195
|
# Buffer cloud output or local-bind activity summaries between
|
|
12192
12196
|
# ticks so the server accumulates a real-time, safe display log.
|
|
@@ -12225,6 +12229,7 @@ class RuntimeDaemon:
|
|
|
12225
12229
|
progress_percent,
|
|
12226
12230
|
current_step,
|
|
12227
12231
|
activity_lines=output_lines,
|
|
12232
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12228
12233
|
)
|
|
12229
12234
|
else:
|
|
12230
12235
|
await reporter.report_progress(
|
|
@@ -12232,6 +12237,7 @@ class RuntimeDaemon:
|
|
|
12232
12237
|
progress_percent,
|
|
12233
12238
|
current_step,
|
|
12234
12239
|
output_lines=output_lines,
|
|
12240
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12235
12241
|
)
|
|
12236
12242
|
|
|
12237
12243
|
async def _progress_ticker():
|
|
@@ -12352,6 +12358,7 @@ class RuntimeDaemon:
|
|
|
12352
12358
|
task.task_id, 10,
|
|
12353
12359
|
f"agent_fallback: retrying with {agent.agent_id}",
|
|
12354
12360
|
output_lines=[f"[daemon] Agent {fallback_label}, switching to {agent.agent_id}"],
|
|
12361
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12355
12362
|
)
|
|
12356
12363
|
|
|
12357
12364
|
# Re-run with fallback agent
|
|
@@ -12601,6 +12608,7 @@ class RuntimeDaemon:
|
|
|
12601
12608
|
f"[daemon] Agent {post_validation_failure_label} during validation, "
|
|
12602
12609
|
f"switching to {agent.agent_id}",
|
|
12603
12610
|
],
|
|
12611
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
12604
12612
|
)
|
|
12605
12613
|
result = await self.process_manager.run_agent(
|
|
12606
12614
|
agent, task, workspace_path, on_chunk=on_output_chunk,
|
|
@@ -12874,7 +12882,7 @@ class RuntimeDaemon:
|
|
|
12874
12882
|
)
|
|
12875
12883
|
|
|
12876
12884
|
if result.status == "success" and task.critic_review_required:
|
|
12877
|
-
await reporter.report_progress(task.task_id, 95, "running_critic_review")
|
|
12885
|
+
await reporter.report_progress(task.task_id, 95, "running_critic_review", execution_attempt_id=task.execution_attempt_id)
|
|
12878
12886
|
result.critic_review = await self._run_runtime_critic_review(
|
|
12879
12887
|
task,
|
|
12880
12888
|
agent,
|
|
@@ -12893,7 +12901,7 @@ class RuntimeDaemon:
|
|
|
12893
12901
|
or "validation_retry_exhausted"
|
|
12894
12902
|
)
|
|
12895
12903
|
result.execution_attempt_id = task.execution_attempt_id
|
|
12896
|
-
await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed")
|
|
12904
|
+
await reporter.report_progress(task.task_id, 100, "completed" if result.status == "success" else "failed", execution_attempt_id=task.execution_attempt_id)
|
|
12897
12905
|
await report_task_complete(result)
|
|
12898
12906
|
|
|
12899
12907
|
logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
|
|
@@ -15763,6 +15771,10 @@ class RuntimeDaemon:
|
|
|
15763
15771
|
broadly `git add -A`s and may rebase/force-push onto the latest
|
|
15764
15772
|
default branch), this adapter never touches the user's real
|
|
15765
15773
|
directory beyond a plain stage+commit+fast-forward-push:
|
|
15774
|
+
- re-verifies the directory is still on the task's workspace_branch
|
|
15775
|
+
before staging and again before pushing: if the user switched
|
|
15776
|
+
branches while the agent ran, the task fails closed instead of
|
|
15777
|
+
committing/pushing the changes onto the wrong branch
|
|
15766
15778
|
- refuses to commit if any changed path (staged, or already
|
|
15767
15779
|
committed by the agent itself since before_sha) looks like a
|
|
15768
15780
|
secret (.env, private keys, etc.) — fails the task closed rather
|
|
@@ -15779,6 +15791,26 @@ class RuntimeDaemon:
|
|
|
15779
15791
|
"""
|
|
15780
15792
|
git = self.workspace_manager._git
|
|
15781
15793
|
try:
|
|
15794
|
+
target_branch = task.workspace_branch
|
|
15795
|
+
if not target_branch:
|
|
15796
|
+
return {"commit_error": (
|
|
15797
|
+
"Refusing to commit: no workspace_branch recorded for this "
|
|
15798
|
+
"local_bind task — cannot verify the directory is still on "
|
|
15799
|
+
"the task's branch."
|
|
15800
|
+
)}
|
|
15801
|
+
current_branch = (
|
|
15802
|
+
await git("rev-parse", "--abbrev-ref", "HEAD", cwd=workspace_path)
|
|
15803
|
+
).strip()
|
|
15804
|
+
if current_branch != target_branch:
|
|
15805
|
+
return {"commit_error": (
|
|
15806
|
+
f"Refusing to commit: local directory is on branch "
|
|
15807
|
+
f"'{current_branch}', but this task started on "
|
|
15808
|
+
f"'{target_branch}'. The branch was switched while the "
|
|
15809
|
+
"task was running (outside Forgexa). Switch back to "
|
|
15810
|
+
f"'{target_branch}' and review the working tree yourself, "
|
|
15811
|
+
"then retry."
|
|
15812
|
+
)}
|
|
15813
|
+
|
|
15782
15814
|
await self._discard_transient_untracked_runtime_files(workspace_path)
|
|
15783
15815
|
status_output = await git("status", "--porcelain", cwd=workspace_path)
|
|
15784
15816
|
dirty_paths = [line[3:] for line in status_output.splitlines() if line.strip()]
|
|
@@ -15898,6 +15930,13 @@ class RuntimeDaemon:
|
|
|
15898
15930
|
return {"push_error": f"failed to read current branch: {exc}"}
|
|
15899
15931
|
if not current_branch or current_branch == "HEAD":
|
|
15900
15932
|
return {"push_error": "not on a branch (detached HEAD) — refusing to push"}
|
|
15933
|
+
if current_branch != target_branch:
|
|
15934
|
+
return {"push_error": (
|
|
15935
|
+
f"Refusing to push: local directory moved to branch "
|
|
15936
|
+
f"'{current_branch}' after commit; this task ran on "
|
|
15937
|
+
f"'{target_branch}'. Nothing was pushed — switch back to "
|
|
15938
|
+
f"'{target_branch}' and retry."
|
|
15939
|
+
)}
|
|
15901
15940
|
|
|
15902
15941
|
try:
|
|
15903
15942
|
await git(
|
|
@@ -3152,6 +3152,7 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3152
3152
|
from forgexa_cli._local_bind import (
|
|
3153
3153
|
load_local_workspaces_mapping,
|
|
3154
3154
|
local_git_remote_identity,
|
|
3155
|
+
local_workspaces_mapping_lock,
|
|
3155
3156
|
save_local_workspaces_mapping,
|
|
3156
3157
|
)
|
|
3157
3158
|
|
|
@@ -3201,10 +3202,74 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3201
3202
|
print("Error: could not resolve current user (are you logged in?)", file=sys.stderr)
|
|
3202
3203
|
sys.exit(1)
|
|
3203
3204
|
|
|
3204
|
-
binding
|
|
3205
|
-
|
|
3206
|
-
|
|
3207
|
-
|
|
3205
|
+
# Reuse the binding this directory is already mapped to when there is
|
|
3206
|
+
# one — the server never sees paths, so directory identity is a local
|
|
3207
|
+
# question (multi-workspace parallel design §6.1). Only create when the
|
|
3208
|
+
# mapping has no candidate or the server-side binding is gone/revoked;
|
|
3209
|
+
# this keeps a re-link from piling up duplicate bindings for the same
|
|
3210
|
+
# directory on projects that allow several bindings per runtime.
|
|
3211
|
+
mapping = load_local_workspaces_mapping()
|
|
3212
|
+
local_path_str = str(local_path)
|
|
3213
|
+
binding = None
|
|
3214
|
+
server_bindings = None
|
|
3215
|
+
for mapping_id, entry in mapping.items():
|
|
3216
|
+
entry_path = str(entry.get("local_path") or "")
|
|
3217
|
+
try:
|
|
3218
|
+
entry_path = str(Path(entry_path).expanduser().resolve())
|
|
3219
|
+
except OSError:
|
|
3220
|
+
pass
|
|
3221
|
+
if (
|
|
3222
|
+
entry_path == local_path_str
|
|
3223
|
+
and entry.get("project_id") == args.project
|
|
3224
|
+
and entry.get("runtime_id") == runtime["id"]
|
|
3225
|
+
and entry.get("user_id") == user_id
|
|
3226
|
+
):
|
|
3227
|
+
if server_bindings is None:
|
|
3228
|
+
server_bindings = _get(f"/projects/{args.project}/local-workspace-bindings")
|
|
3229
|
+
existing = next(
|
|
3230
|
+
(
|
|
3231
|
+
b
|
|
3232
|
+
for b in server_bindings
|
|
3233
|
+
if isinstance(b, dict) and b.get("id") == mapping_id
|
|
3234
|
+
),
|
|
3235
|
+
None,
|
|
3236
|
+
)
|
|
3237
|
+
if isinstance(existing, dict) and existing.get("status") in ("pending", "active", "stale"):
|
|
3238
|
+
binding = existing
|
|
3239
|
+
break
|
|
3240
|
+
|
|
3241
|
+
# Directory↔project bijection (multi-workspace design §7.1): one physical
|
|
3242
|
+
# directory belongs to exactly one project at a time. Re-binding it to a
|
|
3243
|
+
# different project swaps ownership (the old binding goes stale) — refuse
|
|
3244
|
+
# without an explicit --force so picking the wrong project can never
|
|
3245
|
+
# masquerade as "adding another workspace".
|
|
3246
|
+
for entry in mapping.values():
|
|
3247
|
+
entry_path = str(entry.get("local_path") or "")
|
|
3248
|
+
try:
|
|
3249
|
+
entry_path = str(Path(entry_path).expanduser().resolve())
|
|
3250
|
+
except OSError:
|
|
3251
|
+
pass
|
|
3252
|
+
if entry_path == local_path_str and entry.get("project_id") != args.project:
|
|
3253
|
+
if not getattr(args, "force", False):
|
|
3254
|
+
print(
|
|
3255
|
+
f"Error: {local_path} is currently bound to project {entry.get('project_id')}. "
|
|
3256
|
+
"One directory belongs to exactly one project at a time; re-binding releases the old "
|
|
3257
|
+
"binding (it turns stale). If this folder really should change projects, re-run with "
|
|
3258
|
+
"--force. To add another workspace of the SAME project, bind the other folder instead.",
|
|
3259
|
+
file=sys.stderr,
|
|
3260
|
+
)
|
|
3261
|
+
sys.exit(1)
|
|
3262
|
+
print(
|
|
3263
|
+
f"Warning: re-binding {local_path} from project {entry.get('project_id')} to {args.project}",
|
|
3264
|
+
file=sys.stderr,
|
|
3265
|
+
)
|
|
3266
|
+
break
|
|
3267
|
+
|
|
3268
|
+
if binding is None:
|
|
3269
|
+
binding = _post(
|
|
3270
|
+
f"/projects/{args.project}/local-workspace-bindings",
|
|
3271
|
+
{"runtime_id": runtime["id"], "multi_binding_capable": True},
|
|
3272
|
+
)
|
|
3208
3273
|
binding_id = binding.get("id")
|
|
3209
3274
|
if not binding_id:
|
|
3210
3275
|
print("Error: failed to create local workspace binding", file=sys.stderr)
|
|
@@ -3223,8 +3288,6 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3223
3288
|
)
|
|
3224
3289
|
sys.exit(1)
|
|
3225
3290
|
|
|
3226
|
-
mapping = load_local_workspaces_mapping()
|
|
3227
|
-
local_path_str = str(local_path)
|
|
3228
3291
|
# A folder can only ever be validly bound to one binding_id at a time -
|
|
3229
3292
|
# drop any other (necessarily stale) entry already pointing at this same
|
|
3230
3293
|
# path first. Without this, a stale entry left behind by an earlier
|
|
@@ -3232,21 +3295,24 @@ def cmd_project_bind_local(args: argparse.Namespace) -> None:
|
|
|
3232
3295
|
# verification that timed out client-side) can linger alongside the new
|
|
3233
3296
|
# one, and agent_host.py's `_match_local_binding_id` (a plain path scan
|
|
3234
3297
|
# with no tie-breaking) can resolve to the wrong, orphaned binding_id
|
|
3235
|
-
# instead of the one just verified.
|
|
3236
|
-
mapping
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3240
|
-
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3245
|
-
|
|
3246
|
-
|
|
3247
|
-
|
|
3248
|
-
|
|
3249
|
-
|
|
3298
|
+
# instead of the one just verified. The whole read-modify-write runs
|
|
3299
|
+
# under the cross-process mapping lock shared with the Desktop writer.
|
|
3300
|
+
with local_workspaces_mapping_lock():
|
|
3301
|
+
mapping = load_local_workspaces_mapping()
|
|
3302
|
+
mapping = {
|
|
3303
|
+
key: entry
|
|
3304
|
+
for key, entry in mapping.items()
|
|
3305
|
+
if key == binding_id or entry.get("local_path") != local_path_str
|
|
3306
|
+
}
|
|
3307
|
+
mapping[binding_id] = {
|
|
3308
|
+
"project_id": args.project,
|
|
3309
|
+
"user_id": user_id,
|
|
3310
|
+
"runtime_id": runtime["id"],
|
|
3311
|
+
"repo_identity": identity,
|
|
3312
|
+
"local_path": str(local_path),
|
|
3313
|
+
"share_agent_output": True,
|
|
3314
|
+
}
|
|
3315
|
+
save_local_workspaces_mapping(mapping)
|
|
3250
3316
|
|
|
3251
3317
|
_post(f"/projects/{args.project}/local-workspace-bindings/{binding_id}/verify")
|
|
3252
3318
|
deadline = time.monotonic() + 35
|
|
@@ -3302,9 +3368,12 @@ def cmd_project_unbind_local(args: argparse.Namespace) -> None:
|
|
|
3302
3368
|
|
|
3303
3369
|
binding_id, entry = matches[0]
|
|
3304
3370
|
_delete(f"/projects/{args.project}/local-workspace-bindings/{binding_id}")
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3371
|
+
from forgexa_cli._local_bind import local_workspaces_mapping_lock
|
|
3372
|
+
|
|
3373
|
+
with local_workspaces_mapping_lock(): # re-read under the cross-process lock
|
|
3374
|
+
mapping = load_local_workspaces_mapping()
|
|
3375
|
+
mapping.pop(binding_id, None)
|
|
3376
|
+
save_local_workspaces_mapping(mapping)
|
|
3308
3377
|
print(f"Unbound {entry.get('local_path', '')} from project {args.project} (binding {binding_id[:8]})")
|
|
3309
3378
|
|
|
3310
3379
|
|
|
@@ -3924,6 +3993,12 @@ def main() -> None:
|
|
|
3924
3993
|
bl.add_argument("path", help="Absolute path to your local working directory")
|
|
3925
3994
|
bl.add_argument("--project", required=True, help="Project ID")
|
|
3926
3995
|
bl.add_argument("--runtime", default=None, help="Personal runtime ID prefix (if you have more than one)")
|
|
3996
|
+
bl.add_argument(
|
|
3997
|
+
"--force",
|
|
3998
|
+
action="store_true",
|
|
3999
|
+
help="Allow re-binding a directory that currently belongs to a DIFFERENT project "
|
|
4000
|
+
"(the old binding turns stale on the daemon's next validation cycle)",
|
|
4001
|
+
)
|
|
3927
4002
|
ul = proj_sub.add_parser("unbind-local", help="Revoke a local directory binding")
|
|
3928
4003
|
ul.add_argument("--project", required=True, help="Project ID")
|
|
3929
4004
|
ul.add_argument("--binding", default=None, help="Binding ID prefix (if you have more than one for this project)")
|
|
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
|
|
File without changes
|