forgexa-cli 1.21.8__tar.gz → 1.22.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.21.8 → forgexa_cli-1.22.0}/PKG-INFO +1 -1
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/daemon.py +212 -7
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/pyproject.toml +1 -1
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/README.md +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/setup.cfg +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.21.8 → forgexa_cli-1.22.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.22.0"
|
|
@@ -555,7 +555,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
555
555
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
556
556
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
557
557
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
558
|
-
DAEMON_VERSION = "1.
|
|
558
|
+
DAEMON_VERSION = "1.22.0"
|
|
559
559
|
|
|
560
560
|
|
|
561
561
|
def _detect_client_type() -> str:
|
|
@@ -990,6 +990,12 @@ class TaskInfo:
|
|
|
990
990
|
requirement_key: str | None = None
|
|
991
991
|
graph_type: str = "execution"
|
|
992
992
|
analysis_branch: str | None = None
|
|
993
|
+
# Explicit workspace baseline resolved server-side (SI-887). When set,
|
|
994
|
+
# these override the legacy requirement_key-based branch derivation:
|
|
995
|
+
# "requirement_branch" -> checkout workspace_branch, fail closed
|
|
996
|
+
# "default_branch" -> detached HEAD at origin/workspace_branch, never push
|
|
997
|
+
workspace_source_mode: str | None = None
|
|
998
|
+
workspace_branch: str | None = None
|
|
993
999
|
|
|
994
1000
|
|
|
995
1001
|
@dataclass
|
|
@@ -2375,7 +2381,22 @@ class WorkspaceManager:
|
|
|
2375
2381
|
|
|
2376
2382
|
# Use human-readable branch name feature/{requirement_key}
|
|
2377
2383
|
# when requirement_key is available; fallback to feature/{workspace_key}
|
|
2378
|
-
|
|
2384
|
+
#
|
|
2385
|
+
# Explicit server-resolved baseline (SI-887) takes precedence over the
|
|
2386
|
+
# legacy requirement_key derivation:
|
|
2387
|
+
# - "requirement_branch": checkout the given branch, fail closed.
|
|
2388
|
+
# - "default_branch": detached HEAD at origin/<branch> (QA read-only
|
|
2389
|
+
# context) — never probe feature/{key}, never push.
|
|
2390
|
+
ws_source_mode = task.workspace_source_mode
|
|
2391
|
+
detach_head = ws_source_mode == "default_branch"
|
|
2392
|
+
if ws_source_mode == "requirement_branch":
|
|
2393
|
+
branch_name = task.workspace_branch or (
|
|
2394
|
+
f"feature/{task.requirement_key}" if task.requirement_key
|
|
2395
|
+
else f"feature/{workspace_key}"
|
|
2396
|
+
)
|
|
2397
|
+
elif detach_head:
|
|
2398
|
+
branch_name = task.workspace_branch or default_branch
|
|
2399
|
+
elif task.requirement_key:
|
|
2379
2400
|
branch_name = f"feature/{task.requirement_key}"
|
|
2380
2401
|
else:
|
|
2381
2402
|
branch_name = f"feature/{workspace_key}"
|
|
@@ -2408,16 +2429,25 @@ class WorkspaceManager:
|
|
|
2408
2429
|
# reflects the branch from a *previous* analysis run (e.g. a prior Copilot
|
|
2409
2430
|
# analysis) which may have been deleted as part of the fresh restart.
|
|
2410
2431
|
# Only refine/continuation tasks require the branch to already exist.
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2432
|
+
if ws_source_mode == "requirement_branch":
|
|
2433
|
+
# Server-resolved requirement baseline: the branch must exist.
|
|
2434
|
+
expect_branch = True
|
|
2435
|
+
elif detach_head:
|
|
2436
|
+
# Server-resolved default-branch baseline: read-only QA context,
|
|
2437
|
+
# never fail closed on the delivery branch protection.
|
|
2438
|
+
expect_branch = False
|
|
2439
|
+
else:
|
|
2440
|
+
expect_branch = (not is_fresh_start) and (
|
|
2441
|
+
bool(task.analysis_branch) or (
|
|
2442
|
+
bool(task.requirement_key) and task.node_type != "analysis"
|
|
2443
|
+
)
|
|
2414
2444
|
)
|
|
2415
|
-
)
|
|
2416
2445
|
ws_path = await self._create_worktree(
|
|
2417
2446
|
project_dir, repo_url, default_branch, workspace_key, branch_name,
|
|
2418
2447
|
fresh_start=is_fresh_start,
|
|
2419
2448
|
project_key=project_key,
|
|
2420
2449
|
expect_branch=expect_branch,
|
|
2450
|
+
detach_head=detach_head,
|
|
2421
2451
|
)
|
|
2422
2452
|
# After workspace creation, perform a final branch-specific fetch + reset
|
|
2423
2453
|
# to ensure the working tree is at the absolute latest remote state.
|
|
@@ -2427,7 +2457,9 @@ class WorkspaceManager:
|
|
|
2427
2457
|
# may have pushed commits while this runtime's agent was executing.
|
|
2428
2458
|
# A final sync here keeps the workspace current so the agent works
|
|
2429
2459
|
# on the latest codebase and avoids non-fast-forward push failures.
|
|
2430
|
-
|
|
2460
|
+
# Skipped for detach_head (default-branch QA baseline): the detached
|
|
2461
|
+
# worktree path already fetched and pinned HEAD to origin/<branch>.
|
|
2462
|
+
if not is_fresh_start and not detach_head:
|
|
2431
2463
|
sync_success = False
|
|
2432
2464
|
sync_error = ""
|
|
2433
2465
|
try:
|
|
@@ -2893,10 +2925,163 @@ class WorkspaceManager:
|
|
|
2893
2925
|
self._worktree_locks[key] = asyncio.Lock()
|
|
2894
2926
|
return self._worktree_locks[key]
|
|
2895
2927
|
|
|
2928
|
+
async def _refresh_or_invalidate_detached_worktree(
|
|
2929
|
+
self, main_repo: Path, ws_path: Path, branch_name: str, *, project_key: str,
|
|
2930
|
+
) -> bool:
|
|
2931
|
+
"""Refresh an existing healthy detached-mode worktree onto origin/<branch_name>.
|
|
2932
|
+
|
|
2933
|
+
Returns True when the worktree was successfully refreshed/re-pinned and
|
|
2934
|
+
the caller should reuse it as-is. Returns False when it was removed and
|
|
2935
|
+
the caller must fall through to full recreation.
|
|
2936
|
+
|
|
2937
|
+
SI-887 review fix: a worktree still checked out on a real local branch
|
|
2938
|
+
(not yet detached) is a leftover requirement_branch-mode checkout from
|
|
2939
|
+
before a baseline switch. That content must never be silently reused
|
|
2940
|
+
just because the refresh fetch below happens to fail — doing so would
|
|
2941
|
+
run the job against the wrong baseline entirely, not merely stale
|
|
2942
|
+
content. Only an already-detached worktree (same-mode refresh) may
|
|
2943
|
+
tolerate a fetch failure, matching the daemon's existing tolerance of
|
|
2944
|
+
transient fetch errors elsewhere when there is no baseline-identity
|
|
2945
|
+
risk.
|
|
2946
|
+
"""
|
|
2947
|
+
stale_branch_checkout = False
|
|
2948
|
+
try:
|
|
2949
|
+
await self._git("symbolic-ref", "-q", "HEAD", cwd=ws_path)
|
|
2950
|
+
stale_branch_checkout = True
|
|
2951
|
+
except RuntimeError:
|
|
2952
|
+
pass # already detached
|
|
2953
|
+
|
|
2954
|
+
try:
|
|
2955
|
+
await self._git(
|
|
2956
|
+
"fetch", "origin",
|
|
2957
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
2958
|
+
cwd=ws_path, timeout=settings.GIT_FETCH_TIMEOUT,
|
|
2959
|
+
project_key=project_key,
|
|
2960
|
+
)
|
|
2961
|
+
except RuntimeError as exc:
|
|
2962
|
+
if not stale_branch_checkout:
|
|
2963
|
+
logger.warning(
|
|
2964
|
+
"fetch origin/%s failed for QA workspace %s: %s — reusing current checkout",
|
|
2965
|
+
branch_name, ws_path, exc,
|
|
2966
|
+
)
|
|
2967
|
+
return True
|
|
2968
|
+
logger.warning(
|
|
2969
|
+
"fetch origin/%s failed for QA workspace %s while switching off a "
|
|
2970
|
+
"requirement-branch baseline: %s — discarding the stale checkout "
|
|
2971
|
+
"instead of running this job against the wrong baseline",
|
|
2972
|
+
branch_name, ws_path, exc,
|
|
2973
|
+
)
|
|
2974
|
+
await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
|
|
2975
|
+
return False
|
|
2976
|
+
|
|
2977
|
+
try:
|
|
2978
|
+
await self._git(
|
|
2979
|
+
"checkout", "-f", "--detach", f"origin/{branch_name}",
|
|
2980
|
+
cwd=ws_path,
|
|
2981
|
+
)
|
|
2982
|
+
except RuntimeError as exc:
|
|
2983
|
+
logger.warning(
|
|
2984
|
+
"Failed to detach QA workspace %s onto origin/%s: %s — recreating",
|
|
2985
|
+
ws_path, branch_name, exc,
|
|
2986
|
+
)
|
|
2987
|
+
await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
|
|
2988
|
+
return False
|
|
2989
|
+
return True
|
|
2990
|
+
|
|
2991
|
+
async def _create_detached_worktree(
|
|
2992
|
+
self, repo_url: str, main_repo: Path, ws_path: Path, branch_name: str,
|
|
2993
|
+
*, default_branch: str, project_key: str = "default",
|
|
2994
|
+
) -> Path:
|
|
2995
|
+
"""Create or refresh a read-only worktree pinned to origin/<branch_name>.
|
|
2996
|
+
|
|
2997
|
+
Used for server-resolved default-branch QA baselines (SI-887): the
|
|
2998
|
+
worktree is checked out at a DETACHED HEAD so it never collides with
|
|
2999
|
+
the default branch held by _main, never creates/tracks a local branch,
|
|
3000
|
+
and is never pushed. AI job results flow back via the API only.
|
|
3001
|
+
"""
|
|
3002
|
+
if ws_path.exists():
|
|
3003
|
+
if not await self._is_healthy_worktree(ws_path, main_repo=main_repo):
|
|
3004
|
+
logger.warning("Broken worktree detected at %s — removing and recreating", ws_path)
|
|
3005
|
+
await self._remove_broken_worktree(main_repo, ws_path, ws_path.name)
|
|
3006
|
+
else:
|
|
3007
|
+
# Reuse: refresh origin/<branch_name> and re-pin HEAD onto it (or
|
|
3008
|
+
# discard and fall through to full recreation below) — see
|
|
3009
|
+
# _refresh_or_invalidate_detached_worktree for the baseline-switch
|
|
3010
|
+
# safety rationale.
|
|
3011
|
+
if await self._refresh_or_invalidate_detached_worktree(
|
|
3012
|
+
main_repo, ws_path, branch_name, project_key=project_key,
|
|
3013
|
+
):
|
|
3014
|
+
return ws_path
|
|
3015
|
+
|
|
3016
|
+
# Ensure _main exists (single-branch clone of the default branch).
|
|
3017
|
+
if not main_repo.exists():
|
|
3018
|
+
await self._clone_repo(
|
|
3019
|
+
repo_url, main_repo, branch=default_branch, project_key=project_key,
|
|
3020
|
+
)
|
|
3021
|
+
else:
|
|
3022
|
+
# Same interrupted-clone guard as the feature-branch path: without a
|
|
3023
|
+
# valid HEAD / readable tree, worktree add would always fail.
|
|
3024
|
+
try:
|
|
3025
|
+
await self._git("rev-parse", "HEAD", cwd=main_repo)
|
|
3026
|
+
await self._git("rev-parse", "HEAD^{tree}", cwd=main_repo)
|
|
3027
|
+
except RuntimeError:
|
|
3028
|
+
logger.warning(
|
|
3029
|
+
"_main repo at %s has no valid HEAD (likely interrupted initial clone) "
|
|
3030
|
+
"— deleting and re-cloning from scratch",
|
|
3031
|
+
main_repo,
|
|
3032
|
+
)
|
|
3033
|
+
await self._safe_rmtree_main(main_repo)
|
|
3034
|
+
if main_repo.exists():
|
|
3035
|
+
raise RuntimeError(
|
|
3036
|
+
f"Cannot re-clone: {main_repo} could not be fully removed "
|
|
3037
|
+
"(files may be locked by another process). "
|
|
3038
|
+
"Please stop the daemon, manually delete it, then restart."
|
|
3039
|
+
)
|
|
3040
|
+
await self._clone_repo(
|
|
3041
|
+
repo_url, main_repo, branch=default_branch, project_key=project_key,
|
|
3042
|
+
)
|
|
3043
|
+
|
|
3044
|
+
# Refresh origin/<branch_name>. _main is a single-branch clone of the
|
|
3045
|
+
# default branch and branch_name is the server-resolved baseline
|
|
3046
|
+
# (normally that same default branch); fetch the explicit refspec so
|
|
3047
|
+
# the pin below always has a fresh ref.
|
|
3048
|
+
try:
|
|
3049
|
+
await self._git(
|
|
3050
|
+
"fetch", "origin",
|
|
3051
|
+
f"{branch_name}:refs/remotes/origin/{branch_name}",
|
|
3052
|
+
cwd=main_repo, timeout=settings.GIT_FETCH_TIMEOUT,
|
|
3053
|
+
project_key=project_key,
|
|
3054
|
+
)
|
|
3055
|
+
except RuntimeError as exc:
|
|
3056
|
+
raise RuntimeError(
|
|
3057
|
+
f"Failed to fetch QA baseline branch '{branch_name}' from remote: {exc}"
|
|
3058
|
+
) from exc
|
|
3059
|
+
|
|
3060
|
+
try:
|
|
3061
|
+
await self._git(
|
|
3062
|
+
"worktree", "add", "--detach", str(ws_path), f"origin/{branch_name}",
|
|
3063
|
+
cwd=main_repo,
|
|
3064
|
+
)
|
|
3065
|
+
except RuntimeError:
|
|
3066
|
+
# A stale worktree registration may linger after a manual delete.
|
|
3067
|
+
await self._git("worktree", "prune", cwd=main_repo)
|
|
3068
|
+
try:
|
|
3069
|
+
await self._git(
|
|
3070
|
+
"worktree", "add", "--detach", str(ws_path), f"origin/{branch_name}",
|
|
3071
|
+
cwd=main_repo,
|
|
3072
|
+
)
|
|
3073
|
+
except RuntimeError as exc:
|
|
3074
|
+
self._safe_rmtree(ws_path)
|
|
3075
|
+
raise RuntimeError(
|
|
3076
|
+
f"Failed to create detached QA workspace at origin/{branch_name}: {exc}"
|
|
3077
|
+
) from exc
|
|
3078
|
+
return ws_path
|
|
3079
|
+
|
|
2896
3080
|
async def _create_worktree(
|
|
2897
3081
|
self, project_dir: Path, repo_url: str, default_branch: str,
|
|
2898
3082
|
workspace_key: str, branch_name: str, *, fresh_start: bool = False,
|
|
2899
3083
|
project_key: str = "default", expect_branch: bool = False,
|
|
3084
|
+
detach_head: bool = False,
|
|
2900
3085
|
) -> Path:
|
|
2901
3086
|
# Serialize all _create_worktree calls for the same project to prevent
|
|
2902
3087
|
# concurrent tasks from racing on _main (shutil.rmtree / git clone / worktree add).
|
|
@@ -2908,16 +3093,27 @@ class WorkspaceManager:
|
|
|
2908
3093
|
fresh_start=fresh_start,
|
|
2909
3094
|
project_key=project_key,
|
|
2910
3095
|
expect_branch=expect_branch,
|
|
3096
|
+
detach_head=detach_head,
|
|
2911
3097
|
)
|
|
2912
3098
|
|
|
2913
3099
|
async def _create_worktree_impl(
|
|
2914
3100
|
self, project_dir: Path, repo_url: str, default_branch: str,
|
|
2915
3101
|
workspace_key: str, branch_name: str, *, fresh_start: bool = False,
|
|
2916
3102
|
project_key: str = "default", expect_branch: bool = False,
|
|
3103
|
+
detach_head: bool = False,
|
|
2917
3104
|
) -> Path:
|
|
2918
3105
|
main_repo = project_dir / "_main"
|
|
2919
3106
|
ws_path = project_dir / workspace_key
|
|
2920
3107
|
|
|
3108
|
+
if detach_head:
|
|
3109
|
+
# Server-resolved default-branch baseline (SI-887 QA context):
|
|
3110
|
+
# dedicated path that never touches feature/* refs and pins the
|
|
3111
|
+
# worktree to a detached HEAD at origin/<branch_name>.
|
|
3112
|
+
return await self._create_detached_worktree(
|
|
3113
|
+
repo_url, main_repo, ws_path, branch_name,
|
|
3114
|
+
default_branch=default_branch, project_key=project_key,
|
|
3115
|
+
)
|
|
3116
|
+
|
|
2921
3117
|
if ws_path.exists():
|
|
2922
3118
|
# Validate that this worktree is healthy (its .git file points to a
|
|
2923
3119
|
# valid main repo). Worktrees break when directories are moved.
|
|
@@ -9540,6 +9736,13 @@ class RuntimeDaemon:
|
|
|
9540
9736
|
user_prompt = aj.get("user_prompt", "")
|
|
9541
9737
|
input_ctx = aj.get("input_context", {})
|
|
9542
9738
|
ai_workspace_scope = _resolve_ai_job_workspace_scope(aj)
|
|
9739
|
+
# Server-resolved QA workspace baseline (SI-887). Top-level field wins;
|
|
9740
|
+
# the input_context copy is the fallback for older payload shapes.
|
|
9741
|
+
# When absent, prepare_workspace keeps its legacy requirement_key
|
|
9742
|
+
# derivation so this daemon stays compatible with older servers.
|
|
9743
|
+
qa_ws_source = aj.get("qa_workspace_source") or (
|
|
9744
|
+
input_ctx.get("qa_workspace_source") if isinstance(input_ctx, dict) else None
|
|
9745
|
+
) or {}
|
|
9543
9746
|
|
|
9544
9747
|
reporter_url = f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/ai-jobs/{job_id}"
|
|
9545
9748
|
# Use workspace-level key (project:requirement or project:job_id) so AI jobs
|
|
@@ -9615,6 +9818,8 @@ class RuntimeDaemon:
|
|
|
9615
9818
|
requirement_workflow_id=None,
|
|
9616
9819
|
requirement_key=requirement_key,
|
|
9617
9820
|
graph_type="ai_job",
|
|
9821
|
+
workspace_source_mode=qa_ws_source.get("mode"),
|
|
9822
|
+
workspace_branch=qa_ws_source.get("branch"),
|
|
9618
9823
|
)
|
|
9619
9824
|
workspace_path = await self.workspace_manager.prepare_workspace(
|
|
9620
9825
|
project_info, fake_task,
|
|
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
|