forgexa-cli 1.58.2__tar.gz → 1.59.0__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.58.2 → forgexa_cli-1.59.0}/PKG-INFO +1 -1
  2. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/_local_bind.py +57 -0
  4. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/daemon.py +92 -12
  5. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/main.py +99 -24
  6. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
  7. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/pyproject.toml +1 -1
  8. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/README.md +0 -0
  9. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/_build_config.py +0 -0
  10. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/agent_core.py +0 -0
  11. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/autoupgrade.py +0 -0
  12. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/zcode_protocol.py +0 -0
  15. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  16. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  17. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
  18. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/requires.txt +0 -0
  19. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/top_level.txt +0 -0
  20. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/setup.cfg +0 -0
  21. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_auth_and_runtime_commands.py +0 -0
  22. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_autoupgrade.py +0 -0
  23. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_check_command.py +0 -0
  24. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
  25. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_local_bind_commands.py +0 -0
  26. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_runtime_credentials.py +0 -0
  27. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_session_credentials.py +0 -0
  28. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_silent_install.py +0 -0
  29. {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/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.58.2
3
+ Version: 1.59.0
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.58.2"
2
+ __version__ = "1.59.0"
@@ -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)?/?$")
@@ -1058,6 +1058,32 @@ except (ImportError, ModuleNotFoundError):
1058
1058
  def GIT_CLONE_TIMEOUT(self) -> int:
1059
1059
  return int(os.environ.get("GIT_CLONE_TIMEOUT", "1800"))
1060
1060
 
1061
+ @property
1062
+ def GIT_WORKTREE_TIMEOUT(self) -> int:
1063
+ """Timeout (seconds) for `git worktree add` operations.
1064
+
1065
+ `worktree add` materialises a full checkout of the target branch
1066
+ into the new worktree. On large repos (e.g. HP SI) or busy disks
1067
+ this easily exceeds the 120 s default for ordinary git commands
1068
+ (SI-1181: a fresh-start worktree add timed out after 120 s and
1069
+ failed the node even though the checkout was still progressing).
1070
+
1071
+ Precedence:
1072
+ 1. GIT_WORKTREE_TIMEOUT env var (explicit override)
1073
+ 2. GIT_CLONE_TIMEOUT env var (backwards compat — an operator
1074
+ who raised the clone timeout for a huge repo wants local
1075
+ checkouts to get comparable headroom)
1076
+ 3. 600 s default (10 min — local disk I/O only, no network,
1077
+ so shorter than the clone/fetch/push ceiling)
1078
+ """
1079
+ env_worktree = os.environ.get("GIT_WORKTREE_TIMEOUT")
1080
+ if env_worktree:
1081
+ return int(env_worktree)
1082
+ env_clone = os.environ.get("GIT_CLONE_TIMEOUT")
1083
+ if env_clone:
1084
+ return int(env_clone)
1085
+ return 600
1086
+
1061
1087
  @property
1062
1088
  def GIT_FETCH_TIMEOUT(self) -> int:
1063
1089
  """Timeout (seconds) for incremental git fetch operations.
@@ -1170,7 +1196,7 @@ except (ImportError, ModuleNotFoundError):
1170
1196
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1171
1197
  # Kept in sync with pyproject.toml version via bump-version.sh.
1172
1198
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1173
- DAEMON_VERSION = "1.58.2"
1199
+ DAEMON_VERSION = "1.59.0"
1174
1200
 
1175
1201
 
1176
1202
  def _detect_client_type() -> str:
@@ -5436,7 +5462,8 @@ class WorkspaceManager:
5436
5462
  ) from recovery_exc
5437
5463
  self._safe_rmtree(ws_path)
5438
5464
  raise RuntimeError(
5439
- f"Failed to create clean fresh worktree for branch '{branch_name}'. "
5465
+ f"Failed to create clean fresh worktree for branch '{branch_name}': "
5466
+ f"{self._summarize_git_error(exc)}. "
5440
5467
  "Refusing to reuse a stale local branch."
5441
5468
  ) from exc
5442
5469
  else:
@@ -5472,13 +5499,18 @@ class WorkspaceManager:
5472
5499
  """Optional cleanup after task. For now, keep workspaces for debugging."""
5473
5500
  pass
5474
5501
 
5475
- async def _git(self, *args: str, cwd: Path | str | None = None, timeout: int = 120,
5502
+ async def _git(self, *args: str, cwd: Path | str | None = None, timeout: int | None = None,
5476
5503
  project_key: str | None = None) -> str:
5477
5504
  """Run a git command safely using exec (no shell injection).
5478
5505
 
5479
5506
  If a project SSH key is registered, remote-touching commands
5480
5507
  (clone, fetch, pull, push) will use GIT_SSH_COMMAND.
5481
5508
 
5509
+ Timeout resolution (SI-1181): when ``timeout`` is None the default
5510
+ is 120 s for ordinary git commands, but `worktree add` — which
5511
+ materialises a full checkout of the branch — gets the dedicated
5512
+ GIT_WORKTREE_TIMEOUT (default 600 s) instead.
5513
+
5482
5514
  Process-group isolation: git is started in its own session so that
5483
5515
  SIGKILL on timeout propagates to all of git's children (especially
5484
5516
  the ssh subprocess that git forks for remote operations). Without
@@ -5491,6 +5523,15 @@ class WorkspaceManager:
5491
5523
  accepts but never sends the git protocol banner) is detected within
5492
5524
  ~90 s rather than waiting forever.
5493
5525
  """
5526
+ if timeout is None:
5527
+ # `git worktree add` is a full checkout of the target branch;
5528
+ # on large repos (e.g. HP SI) or busy disks it legitimately
5529
+ # exceeds the 120 s default for ordinary git commands.
5530
+ timeout = (
5531
+ settings.GIT_WORKTREE_TIMEOUT
5532
+ if args[:2] == ("worktree", "add")
5533
+ else 120
5534
+ )
5494
5535
  env = None
5495
5536
  git_prefix_args: list[str] = []
5496
5537
  # Inject SSH key for commands that touch the remote
@@ -8466,6 +8507,7 @@ class ProgressReporter:
8466
8507
  files_changed: list[str] | None = None,
8467
8508
  lines_added: int = 0,
8468
8509
  lines_removed: int = 0,
8510
+ execution_attempt_id: str | None = None,
8469
8511
  ) -> bool:
8470
8512
  output_lines = [
8471
8513
  *self._pending_output_lines.get(task_id, []),
@@ -8496,6 +8538,7 @@ class ProgressReporter:
8496
8538
  "files_changed": files_changed,
8497
8539
  "lines_added": lines_added,
8498
8540
  "lines_removed": lines_removed,
8541
+ "execution_attempt_id": execution_attempt_id,
8499
8542
  },
8500
8543
  timeout=10,
8501
8544
  )
@@ -8976,7 +9019,7 @@ class HeartbeatService:
8976
9019
  "active_tasks": self._active_tasks,
8977
9020
  "available_agents": self._agents,
8978
9021
  "capabilities": {
8979
- "local_workspace_bindings": {"protocol_version": 3},
9022
+ "local_workspace_bindings": {"protocol_version": 4},
8980
9023
  "local_commit_and_push": {"protocol_version": 1},
8981
9024
  "local_fresh_branch_reset": {
8982
9025
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
@@ -9346,7 +9389,7 @@ class ServerConnection:
9346
9389
  "capabilities": {
9347
9390
  "platform": platform.platform(),
9348
9391
  "python_version": platform.python_version(),
9349
- "local_workspace_bindings": {"protocol_version": 3},
9392
+ "local_workspace_bindings": {"protocol_version": 4},
9350
9393
  "local_commit_and_push": {"protocol_version": 1},
9351
9394
  "local_fresh_branch_reset": {
9352
9395
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
@@ -9766,7 +9809,7 @@ class ServerConnection:
9766
9809
  "capabilities": {
9767
9810
  "platform": platform.platform(),
9768
9811
  "python_version": platform.python_version(),
9769
- "local_workspace_bindings": {"protocol_version": 3},
9812
+ "local_workspace_bindings": {"protocol_version": 4},
9770
9813
  "local_commit_and_push": {"protocol_version": 1},
9771
9814
  "local_fresh_branch_reset": {
9772
9815
  "protocol_version": _LOCAL_FRESH_BRANCH_RESET_PROTOCOL_VERSION,
@@ -11982,7 +12025,7 @@ class RuntimeDaemon:
11982
12025
  task.task_id,
11983
12026
  project_lock_key,
11984
12027
  )
11985
- 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)
11986
12029
  await project_lock.acquire()
11987
12030
  workspace_execution_lock: _WorkspaceFileLock | None = None
11988
12031
  local_bind_file_lock = None
@@ -12042,7 +12085,7 @@ class RuntimeDaemon:
12042
12085
  ))
12043
12086
  return
12044
12087
 
12045
- 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)
12046
12089
  workspace_execution_lock = await self.workspace_manager.acquire_workspace_execution_lock(
12047
12090
  task.project,
12048
12091
  task,
@@ -12077,7 +12120,7 @@ class RuntimeDaemon:
12077
12120
  return
12078
12121
 
12079
12122
  # 2. Prepare workspace
12080
- 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)
12081
12124
  workspace_path = await self.workspace_manager.prepare_workspace(
12082
12125
  task.project, task, runtime_id=conn.runtime_id,
12083
12126
  )
@@ -12096,6 +12139,7 @@ class RuntimeDaemon:
12096
12139
  8,
12097
12140
  "preparing_attachments",
12098
12141
  output_lines=["[daemon] Preparing task attachments"],
12142
+ execution_attempt_id=task.execution_attempt_id,
12099
12143
  )
12100
12144
  attachment_staging_dir = await self._stage_task_attachments(task, conn)
12101
12145
  except _TaskAttachmentDownloadError as exc:
@@ -12105,6 +12149,7 @@ class RuntimeDaemon:
12105
12149
  8,
12106
12150
  "attachment_download_failed",
12107
12151
  output_lines=["[daemon] Required task attachment could not be downloaded"],
12152
+ execution_attempt_id=task.execution_attempt_id,
12108
12153
  )
12109
12154
  await report_task_complete(TaskResult(
12110
12155
  status="failed",
@@ -12145,7 +12190,7 @@ class RuntimeDaemon:
12145
12190
  )
12146
12191
 
12147
12192
  # 3. Run agent with real-time output streaming + periodic progress heartbeat
12148
- 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)
12149
12194
 
12150
12195
  # Buffer cloud output or local-bind activity summaries between
12151
12196
  # ticks so the server accumulates a real-time, safe display log.
@@ -12184,6 +12229,7 @@ class RuntimeDaemon:
12184
12229
  progress_percent,
12185
12230
  current_step,
12186
12231
  activity_lines=output_lines,
12232
+ execution_attempt_id=task.execution_attempt_id,
12187
12233
  )
12188
12234
  else:
12189
12235
  await reporter.report_progress(
@@ -12191,6 +12237,7 @@ class RuntimeDaemon:
12191
12237
  progress_percent,
12192
12238
  current_step,
12193
12239
  output_lines=output_lines,
12240
+ execution_attempt_id=task.execution_attempt_id,
12194
12241
  )
12195
12242
 
12196
12243
  async def _progress_ticker():
@@ -12311,6 +12358,7 @@ class RuntimeDaemon:
12311
12358
  task.task_id, 10,
12312
12359
  f"agent_fallback: retrying with {agent.agent_id}",
12313
12360
  output_lines=[f"[daemon] Agent {fallback_label}, switching to {agent.agent_id}"],
12361
+ execution_attempt_id=task.execution_attempt_id,
12314
12362
  )
12315
12363
 
12316
12364
  # Re-run with fallback agent
@@ -12560,6 +12608,7 @@ class RuntimeDaemon:
12560
12608
  f"[daemon] Agent {post_validation_failure_label} during validation, "
12561
12609
  f"switching to {agent.agent_id}",
12562
12610
  ],
12611
+ execution_attempt_id=task.execution_attempt_id,
12563
12612
  )
12564
12613
  result = await self.process_manager.run_agent(
12565
12614
  agent, task, workspace_path, on_chunk=on_output_chunk,
@@ -12833,7 +12882,7 @@ class RuntimeDaemon:
12833
12882
  )
12834
12883
 
12835
12884
  if result.status == "success" and task.critic_review_required:
12836
- 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)
12837
12886
  result.critic_review = await self._run_runtime_critic_review(
12838
12887
  task,
12839
12888
  agent,
@@ -12852,7 +12901,7 @@ class RuntimeDaemon:
12852
12901
  or "validation_retry_exhausted"
12853
12902
  )
12854
12903
  result.execution_attempt_id = task.execution_attempt_id
12855
- 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)
12856
12905
  await report_task_complete(result)
12857
12906
 
12858
12907
  logger.info("Task %s completed: %s (agent=%s)", task.task_id, result.status, agent.agent_id)
@@ -15722,6 +15771,10 @@ class RuntimeDaemon:
15722
15771
  broadly `git add -A`s and may rebase/force-push onto the latest
15723
15772
  default branch), this adapter never touches the user's real
15724
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
15725
15778
  - refuses to commit if any changed path (staged, or already
15726
15779
  committed by the agent itself since before_sha) looks like a
15727
15780
  secret (.env, private keys, etc.) — fails the task closed rather
@@ -15738,6 +15791,26 @@ class RuntimeDaemon:
15738
15791
  """
15739
15792
  git = self.workspace_manager._git
15740
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
+
15741
15814
  await self._discard_transient_untracked_runtime_files(workspace_path)
15742
15815
  status_output = await git("status", "--porcelain", cwd=workspace_path)
15743
15816
  dirty_paths = [line[3:] for line in status_output.splitlines() if line.strip()]
@@ -15857,6 +15930,13 @@ class RuntimeDaemon:
15857
15930
  return {"push_error": f"failed to read current branch: {exc}"}
15858
15931
  if not current_branch or current_branch == "HEAD":
15859
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
+ )}
15860
15940
 
15861
15941
  try:
15862
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 = _post(
3205
- f"/projects/{args.project}/local-workspace-bindings",
3206
- {"runtime_id": runtime["id"]},
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
- key: entry
3238
- for key, entry in mapping.items()
3239
- if key == binding_id or entry.get("local_path") != local_path_str
3240
- }
3241
- mapping[binding_id] = {
3242
- "project_id": args.project,
3243
- "user_id": user_id,
3244
- "runtime_id": runtime["id"],
3245
- "repo_identity": identity,
3246
- "local_path": str(local_path),
3247
- "share_agent_output": True,
3248
- }
3249
- save_local_workspaces_mapping(mapping)
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
- mapping = load_local_workspaces_mapping() # re-read in case of concurrent writes
3306
- mapping.pop(binding_id, None)
3307
- save_local_workspaces_mapping(mapping)
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)")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.58.2
3
+ Version: 1.59.0
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.58.2"
3
+ version = "1.59.0"
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"
File without changes
File without changes