forgexa-cli 1.68.1__tar.gz → 1.69.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.68.1 → forgexa_cli-1.69.0}/PKG-INFO +1 -1
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/daemon.py +131 -4
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/pyproject.toml +1 -1
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/README.md +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/aws_evidence.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/aws_logs_evidence.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli/zcode_protocol.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/setup.cfg +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.0}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.68.1 → forgexa_cli-1.69.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.69.0"
|
|
@@ -1234,7 +1234,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
1234
1234
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
1235
1235
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
1236
1236
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
1237
|
-
DAEMON_VERSION = "1.
|
|
1237
|
+
DAEMON_VERSION = "1.69.0"
|
|
1238
1238
|
|
|
1239
1239
|
|
|
1240
1240
|
def _detect_client_type() -> str:
|
|
@@ -3655,6 +3655,8 @@ class WorkspaceManager:
|
|
|
3655
3655
|
Validates the daemon-private mapping entry (binding_id -> local_path),
|
|
3656
3656
|
the directory's git remote identity, branch, and tracked changes.
|
|
3657
3657
|
Pre-existing untracked and ignored files remain user-owned and local.
|
|
3658
|
+
Uncommitted task-owned requirement artifacts are backed up locally
|
|
3659
|
+
before restoring their committed baseline. User code is never reset.
|
|
3658
3660
|
Ordinary local-bind tasks perform NO destructive git operation
|
|
3659
3661
|
(no reset --hard, clean, branch deletion, or implicit branch switch)
|
|
3660
3662
|
on the user's real directory. The only exception is an explicitly
|
|
@@ -3747,9 +3749,14 @@ class WorkspaceManager:
|
|
|
3747
3749
|
"status", "--porcelain=v1", "--untracked-files=no", "-z", cwd=local_path,
|
|
3748
3750
|
)
|
|
3749
3751
|
dirty_paths = _git_status_paths(status_output)
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3752
|
+
recoverable_paths = self._local_requirement_artifact_paths(task, local_path)
|
|
3753
|
+
user_dirty_paths = [
|
|
3754
|
+
path for path in dirty_paths
|
|
3755
|
+
if path not in recoverable_paths or not path.startswith("docs/requirements/")
|
|
3756
|
+
]
|
|
3757
|
+
if user_dirty_paths:
|
|
3758
|
+
shown = ", ".join(user_dirty_paths[:20])
|
|
3759
|
+
more = f" (+{len(user_dirty_paths) - 20} more)" if len(user_dirty_paths) > 20 else ""
|
|
3753
3760
|
raise RuntimeError(
|
|
3754
3761
|
"local_binding_unavailable: local directory has uncommitted tracked changes, "
|
|
3755
3762
|
f"refusing to overwrite user work: {shown}{more}. Commit or stash these "
|
|
@@ -3796,6 +3803,7 @@ class WorkspaceManager:
|
|
|
3796
3803
|
remote_head = (
|
|
3797
3804
|
await self._git("rev-parse", f"origin/{target_branch}", cwd=local_path)
|
|
3798
3805
|
).strip()
|
|
3806
|
+
await self._recover_local_requirement_artifacts(local_path, task, recoverable_paths)
|
|
3799
3807
|
if local_head != remote_head:
|
|
3800
3808
|
await self._check_local_untracked_overwrites(local_path, remote_head)
|
|
3801
3809
|
try:
|
|
@@ -3841,6 +3849,7 @@ class WorkspaceManager:
|
|
|
3841
3849
|
f"local branch is not at origin/{default_branch}; refusing to bootstrap from "
|
|
3842
3850
|
"unpushed or stale history"
|
|
3843
3851
|
)
|
|
3852
|
+
await self._recover_local_requirement_artifacts(local_path, task, recoverable_paths)
|
|
3844
3853
|
|
|
3845
3854
|
if (
|
|
3846
3855
|
task.node_type == "analysis"
|
|
@@ -3881,8 +3890,126 @@ class WorkspaceManager:
|
|
|
3881
3890
|
f"HEAD after fetch — cross-phase handoff incomplete: {', '.join(missing_shas)}"
|
|
3882
3891
|
)
|
|
3883
3892
|
|
|
3893
|
+
task.local_untracked_paths = {
|
|
3894
|
+
path for path in (await self._git(
|
|
3895
|
+
"ls-files", "--others", "-z", cwd=local_path,
|
|
3896
|
+
)).split("\0") if path
|
|
3897
|
+
}
|
|
3884
3898
|
return local_path
|
|
3885
3899
|
|
|
3900
|
+
@staticmethod
|
|
3901
|
+
def _local_requirement_artifact_paths(task: TaskInfo, local_path: Path | None = None) -> set[str]:
|
|
3902
|
+
key = str(task.requirement_key or "")
|
|
3903
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", key):
|
|
3904
|
+
return set()
|
|
3905
|
+
prefix = f"docs/requirements/{key}/"
|
|
3906
|
+
data = task.input_data or {}
|
|
3907
|
+
context = data.get("context") or {}
|
|
3908
|
+
names = {
|
|
3909
|
+
name for outputs in _ANALYSIS_OUTPUTS_BY_TYPE.values() for name in outputs
|
|
3910
|
+
} | {
|
|
3911
|
+
"design.md", "test-cases.json", "coverage-matrix.json",
|
|
3912
|
+
"test-evidence.json", "test-report.md", "release-note.md",
|
|
3913
|
+
}
|
|
3914
|
+
names.update(
|
|
3915
|
+
_get_design_output_for_type(requirement_type)
|
|
3916
|
+
for requirement_type in _ANALYSIS_OUTPUTS_BY_TYPE
|
|
3917
|
+
)
|
|
3918
|
+
paths: set[str] = set()
|
|
3919
|
+
for values in (data, context):
|
|
3920
|
+
for field_name in ("analysis_output_dir", "output_dir"):
|
|
3921
|
+
directory = str(values.get(field_name) or "").replace("\\", "/").rstrip("/")
|
|
3922
|
+
if (
|
|
3923
|
+
directory.startswith(prefix)
|
|
3924
|
+
and not any(part in ("", ".", "..") for part in directory.split("/"))
|
|
3925
|
+
):
|
|
3926
|
+
paths.update(f"{directory}/{name}" for name in names)
|
|
3927
|
+
if local_path is not None:
|
|
3928
|
+
for manifest_path in sorted(paths):
|
|
3929
|
+
if not manifest_path.endswith("/test-cases.json"):
|
|
3930
|
+
continue
|
|
3931
|
+
manifest_file = local_path / manifest_path
|
|
3932
|
+
if not manifest_file.resolve().is_relative_to(local_path.resolve()):
|
|
3933
|
+
continue
|
|
3934
|
+
try:
|
|
3935
|
+
manifest = json.loads(manifest_file.read_text(encoding="utf-8"))
|
|
3936
|
+
except (OSError, ValueError, UnicodeError):
|
|
3937
|
+
continue
|
|
3938
|
+
cases = manifest.get("test_cases") if isinstance(manifest, dict) else None
|
|
3939
|
+
if not isinstance(cases, list):
|
|
3940
|
+
continue
|
|
3941
|
+
for case in cases:
|
|
3942
|
+
if not isinstance(case, dict):
|
|
3943
|
+
continue
|
|
3944
|
+
case_id = str(case.get("id") or "")
|
|
3945
|
+
script_path = str(case.get("script_path") or "").replace("\\", "/")
|
|
3946
|
+
parts = script_path.split("/")
|
|
3947
|
+
if (
|
|
3948
|
+
re.fullmatch(r"TC-\d+", case_id)
|
|
3949
|
+
and len(parts) == 3
|
|
3950
|
+
and parts[0] == "tests"
|
|
3951
|
+
and parts[1] in ("e2e", "unit", "integration")
|
|
3952
|
+
and parts[2].startswith((case_id + "-", case_id + "."))
|
|
3953
|
+
and parts[2].endswith((".spec.ts", ".spec.js", ".test.ts", ".test.js", ".py"))
|
|
3954
|
+
):
|
|
3955
|
+
paths.add(script_path)
|
|
3956
|
+
return paths
|
|
3957
|
+
|
|
3958
|
+
async def _recover_local_requirement_artifacts(
|
|
3959
|
+
self, local_path: Path, task: TaskInfo, recoverable_paths: set[str],
|
|
3960
|
+
) -> None:
|
|
3961
|
+
if not recoverable_paths:
|
|
3962
|
+
return
|
|
3963
|
+
git = self._git
|
|
3964
|
+
status_output = await git(
|
|
3965
|
+
"status", "--porcelain=v1", "--untracked-files=all", "-z", cwd=local_path,
|
|
3966
|
+
)
|
|
3967
|
+
paths = sorted({
|
|
3968
|
+
path for status, path in _git_status_entries(status_output)
|
|
3969
|
+
if path in recoverable_paths
|
|
3970
|
+
and (status == "??" or path.startswith("docs/requirements/"))
|
|
3971
|
+
})
|
|
3972
|
+
if not paths:
|
|
3973
|
+
return
|
|
3974
|
+
workspace_root = local_path.resolve()
|
|
3975
|
+
if any(not (local_path / path).resolve().is_relative_to(workspace_root) for path in paths):
|
|
3976
|
+
raise RuntimeError(
|
|
3977
|
+
"local_binding_unavailable: requirement artifact resolves outside the local "
|
|
3978
|
+
"workspace; refusing to recover files through an external symbolic link"
|
|
3979
|
+
)
|
|
3980
|
+
recovery_ref = f"refs/forgexa/local-artifact-recovery/{task.task_id}/{time.time_ns()}"
|
|
3981
|
+
await git("check-ref-format", recovery_ref, cwd=local_path)
|
|
3982
|
+
name, email = _resolve_git_author(task.project)
|
|
3983
|
+
try:
|
|
3984
|
+
await git(
|
|
3985
|
+
"-c", f"user.name={name}", "-c", f"user.email={email}",
|
|
3986
|
+
"stash", "push", "--include-untracked", "--message",
|
|
3987
|
+
f"Forgexa requirement artifact recovery for {task.task_id}", "--",
|
|
3988
|
+
*(f":(literal){path}" for path in paths), cwd=local_path,
|
|
3989
|
+
)
|
|
3990
|
+
recovery_sha = (await git("rev-parse", "refs/stash", cwd=local_path)).strip()
|
|
3991
|
+
await git("update-ref", recovery_ref, recovery_sha, cwd=local_path)
|
|
3992
|
+
except RuntimeError as exc:
|
|
3993
|
+
raise RuntimeError(
|
|
3994
|
+
"local_binding_unavailable: could not preserve uncommitted requirement "
|
|
3995
|
+
"artifacts; any created backup remains in the local Git stash. "
|
|
3996
|
+
"No user code was discarded."
|
|
3997
|
+
) from exc
|
|
3998
|
+
_append_local_bind_output_log(task.task_id, [
|
|
3999
|
+
f"[daemon] Preserved {len(paths)} unfinished requirement artifact(s) in "
|
|
4000
|
+
f"{recovery_ref}; restored their committed baseline for this task. "
|
|
4001
|
+
f"Recover with git stash apply --index {recovery_ref}.",
|
|
4002
|
+
])
|
|
4003
|
+
logger.info("Preserved local requirement artifacts for task %s in %s", task.task_id, recovery_ref)
|
|
4004
|
+
remaining_status = await git(
|
|
4005
|
+
"status", "--porcelain=v1", "--untracked-files=no", "-z", cwd=local_path,
|
|
4006
|
+
)
|
|
4007
|
+
if _git_status_paths(remaining_status):
|
|
4008
|
+
raise RuntimeError(
|
|
4009
|
+
"local_binding_unavailable: tracked changes remain after requirement artifact "
|
|
4010
|
+
f"recovery; no task was started. Preserved artifacts are in {recovery_ref}."
|
|
4011
|
+
)
|
|
4012
|
+
|
|
3886
4013
|
async def _check_local_untracked_overwrites(self, local_path: Path, revision: str) -> None:
|
|
3887
4014
|
untracked_paths = {
|
|
3888
4015
|
path for path in (await self._git(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|