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.
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/PKG-INFO +1 -1
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/_local_bind.py +57 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/daemon.py +92 -12
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/main.py +99 -24
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/pyproject.toml +1 -1
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/README.md +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/setup.cfg +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.58.2 → forgexa_cli-1.59.0}/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.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.
|
|
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 =
|
|
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":
|
|
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":
|
|
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":
|
|
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
|
|
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
|