forgexa-cli 1.11.4__tar.gz → 1.12.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.11.4 → forgexa_cli-1.12.0}/PKG-INFO +1 -1
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli/daemon.py +179 -45
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/pyproject.toml +1 -1
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/README.md +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/setup.cfg +0 -0
- {forgexa_cli-1.11.4 → forgexa_cli-1.12.0}/tests/test_auth_and_runtime_commands.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.12.0"
|
|
@@ -474,7 +474,7 @@ except (ImportError, ModuleNotFoundError):
|
|
|
474
474
|
# DAEMON_VERSION is the protocol/logic version of the daemon code.
|
|
475
475
|
# Kept in sync with pyproject.toml version via bump-version.sh.
|
|
476
476
|
# CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
|
|
477
|
-
DAEMON_VERSION = "1.
|
|
477
|
+
DAEMON_VERSION = "1.12.0"
|
|
478
478
|
|
|
479
479
|
|
|
480
480
|
def _detect_client_type() -> str:
|
|
@@ -726,6 +726,100 @@ class TaskResult:
|
|
|
726
726
|
git: dict = field(default_factory=dict)
|
|
727
727
|
|
|
728
728
|
|
|
729
|
+
def _prepare_ai_job_output(
|
|
730
|
+
*,
|
|
731
|
+
task_type: str,
|
|
732
|
+
agent_id: str,
|
|
733
|
+
raw_output: str,
|
|
734
|
+
max_content: int,
|
|
735
|
+
) -> tuple[str, list[dict] | None]:
|
|
736
|
+
"""Normalize AIJob output before reporting it back to the server."""
|
|
737
|
+
normalized_output, scenario_items = _normalize_ai_job_output(
|
|
738
|
+
task_type=task_type,
|
|
739
|
+
agent_id=agent_id,
|
|
740
|
+
raw_output=raw_output,
|
|
741
|
+
)
|
|
742
|
+
output_content = normalized_output[-max_content:] if normalized_output else ""
|
|
743
|
+
return output_content, scenario_items
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _normalize_ai_job_output(
|
|
747
|
+
*,
|
|
748
|
+
task_type: str,
|
|
749
|
+
agent_id: str,
|
|
750
|
+
raw_output: str,
|
|
751
|
+
) -> tuple[str, list[dict] | None]:
|
|
752
|
+
"""Extract agent-specific final text before truncating for transport."""
|
|
753
|
+
if not raw_output:
|
|
754
|
+
return "", None
|
|
755
|
+
|
|
756
|
+
if task_type not in {"qa_scenario_generate_prompt", "test_script_generate"}:
|
|
757
|
+
return raw_output, None
|
|
758
|
+
|
|
759
|
+
from app.services.ai_execution_strategy import AIExecutionStrategy
|
|
760
|
+
|
|
761
|
+
extracted_output = AIExecutionStrategy._extract_agent_output(agent_id, raw_output)
|
|
762
|
+
if task_type == "test_script_generate":
|
|
763
|
+
return extracted_output or raw_output, None
|
|
764
|
+
|
|
765
|
+
from app.services.qa_ai_assistant import QAAIAssistant
|
|
766
|
+
|
|
767
|
+
parsed = QAAIAssistant._extract_json(extracted_output)
|
|
768
|
+
if isinstance(parsed, list):
|
|
769
|
+
return json.dumps(parsed), parsed
|
|
770
|
+
if isinstance(parsed, dict):
|
|
771
|
+
items = parsed.get("items")
|
|
772
|
+
if isinstance(items, list):
|
|
773
|
+
return json.dumps(items), items
|
|
774
|
+
if extracted_output and extracted_output != raw_output:
|
|
775
|
+
return extracted_output, None
|
|
776
|
+
return raw_output, None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _extract_ai_job_scripts(
|
|
780
|
+
*,
|
|
781
|
+
output_text: str,
|
|
782
|
+
scenario_ids: list[str],
|
|
783
|
+
workspace_path: Path,
|
|
784
|
+
) -> dict[str, str]:
|
|
785
|
+
scripts: dict[str, str] = {}
|
|
786
|
+
if not scenario_ids or not output_text:
|
|
787
|
+
return scripts
|
|
788
|
+
|
|
789
|
+
import re as _re
|
|
790
|
+
|
|
791
|
+
for sid in scenario_ids:
|
|
792
|
+
pattern = (
|
|
793
|
+
r"##\s*SCRIPT_START::" + _re.escape(sid)
|
|
794
|
+
+ r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
|
|
795
|
+
)
|
|
796
|
+
match = _re.search(pattern, output_text, _re.DOTALL)
|
|
797
|
+
if match:
|
|
798
|
+
scripts[sid] = match.group(1).strip()
|
|
799
|
+
|
|
800
|
+
if not scripts and len(scenario_ids) == 1:
|
|
801
|
+
scripts[scenario_ids[0]] = output_text.strip()
|
|
802
|
+
|
|
803
|
+
if scripts:
|
|
804
|
+
return scripts
|
|
805
|
+
|
|
806
|
+
import glob as _glob
|
|
807
|
+
|
|
808
|
+
for sid in scenario_ids:
|
|
809
|
+
test_files = _glob.glob(
|
|
810
|
+
str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
|
|
811
|
+
recursive=True,
|
|
812
|
+
)
|
|
813
|
+
if test_files:
|
|
814
|
+
try:
|
|
815
|
+
with open(test_files[0], "r") as f:
|
|
816
|
+
scripts[sid] = f.read()
|
|
817
|
+
except Exception:
|
|
818
|
+
pass
|
|
819
|
+
|
|
820
|
+
return scripts
|
|
821
|
+
|
|
822
|
+
|
|
729
823
|
def _resolve_git_author(project: dict) -> tuple[str, str]:
|
|
730
824
|
"""Resolve git commit author (name, email) using a 5-tier fallback chain.
|
|
731
825
|
|
|
@@ -1207,6 +1301,20 @@ class WorkspaceManager:
|
|
|
1207
1301
|
return None
|
|
1208
1302
|
return key_content # Return raw key content, _git will handle it
|
|
1209
1303
|
|
|
1304
|
+
@staticmethod
|
|
1305
|
+
def _clone_args(*, branch: str | None = None) -> tuple[str, ...]:
|
|
1306
|
+
"""Return the standard clone args for daemon workspaces.
|
|
1307
|
+
|
|
1308
|
+
Runtime workspaces only need the current branch contents. They already
|
|
1309
|
+
avoid tags and non-target branches, so making the clone shallow is
|
|
1310
|
+
consistent and prevents very large repositories from timing out while
|
|
1311
|
+
transferring years of irrelevant history.
|
|
1312
|
+
"""
|
|
1313
|
+
args: list[str] = ["clone", "--depth", "1", "--single-branch", "--no-tags"]
|
|
1314
|
+
if branch:
|
|
1315
|
+
args.extend(["--branch", branch])
|
|
1316
|
+
return tuple(args)
|
|
1317
|
+
|
|
1210
1318
|
async def prepare_workspace(self, project: dict, task: TaskInfo) -> Path:
|
|
1211
1319
|
"""Create or reuse a workspace for the given task.
|
|
1212
1320
|
|
|
@@ -1952,8 +2060,7 @@ class WorkspaceManager:
|
|
|
1952
2060
|
# Ensure _main repo is present and up-to-date
|
|
1953
2061
|
if not main_repo.exists():
|
|
1954
2062
|
await self._git(
|
|
1955
|
-
|
|
1956
|
-
"--branch", default_branch,
|
|
2063
|
+
*self._clone_args(branch=default_branch),
|
|
1957
2064
|
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1958
2065
|
)
|
|
1959
2066
|
else:
|
|
@@ -1983,8 +2090,7 @@ class WorkspaceManager:
|
|
|
1983
2090
|
await self._safe_rmtree_main(main_repo)
|
|
1984
2091
|
try:
|
|
1985
2092
|
await self._git(
|
|
1986
|
-
|
|
1987
|
-
"--branch", default_branch,
|
|
2093
|
+
*self._clone_args(branch=default_branch),
|
|
1988
2094
|
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
1989
2095
|
)
|
|
1990
2096
|
except Exception:
|
|
@@ -2016,8 +2122,7 @@ class WorkspaceManager:
|
|
|
2016
2122
|
await self._safe_rmtree_main(main_repo)
|
|
2017
2123
|
try:
|
|
2018
2124
|
await self._git(
|
|
2019
|
-
|
|
2020
|
-
"--branch", default_branch,
|
|
2125
|
+
*self._clone_args(branch=default_branch),
|
|
2021
2126
|
repo_url, str(main_repo), timeout=settings.GIT_CLONE_TIMEOUT,
|
|
2022
2127
|
project_key=project_key,
|
|
2023
2128
|
)
|
|
@@ -2129,7 +2234,7 @@ class WorkspaceManager:
|
|
|
2129
2234
|
except Exception:
|
|
2130
2235
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2131
2236
|
await self._git(
|
|
2132
|
-
|
|
2237
|
+
*self._clone_args(),
|
|
2133
2238
|
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
2134
2239
|
)
|
|
2135
2240
|
# Ensure we're on the correct branch after clone
|
|
@@ -2154,7 +2259,7 @@ class WorkspaceManager:
|
|
|
2154
2259
|
# Fallback to simple clone
|
|
2155
2260
|
ws_path.mkdir(parents=True, exist_ok=True)
|
|
2156
2261
|
await self._git(
|
|
2157
|
-
|
|
2262
|
+
*self._clone_args(),
|
|
2158
2263
|
repo_url, str(ws_path), timeout=settings.GIT_CLONE_TIMEOUT, project_key=project_key,
|
|
2159
2264
|
)
|
|
2160
2265
|
# Ensure we're on the correct branch after clone
|
|
@@ -3604,6 +3709,50 @@ class ProcessManager:
|
|
|
3604
3709
|
result.error = error_clean or result.error
|
|
3605
3710
|
return result
|
|
3606
3711
|
|
|
3712
|
+
@staticmethod
|
|
3713
|
+
def _prepare_copilot_home() -> str:
|
|
3714
|
+
"""Prepare a writable Copilot home under ~/.forgexa for sandboxed runs.
|
|
3715
|
+
|
|
3716
|
+
The systemd daemon runs with ProtectSystem=strict and can only write to
|
|
3717
|
+
~/.forgexa plus the workspace root. Copilot CLI 1.0.64 persists runtime
|
|
3718
|
+
state under ~/.copilot/session-state and its SQLite session store. That
|
|
3719
|
+
causes EROFS failures under the daemon even when interactive shell runs
|
|
3720
|
+
work fine.
|
|
3721
|
+
|
|
3722
|
+
Mirror the user-facing ~/.copilot state into a writable daemon-owned
|
|
3723
|
+
directory, then point COPILOT_HOME there for non-interactive runs.
|
|
3724
|
+
"""
|
|
3725
|
+
source_root = Path.home() / ".copilot"
|
|
3726
|
+
target_root = Path.home() / ".forgexa" / "copilot-home"
|
|
3727
|
+
target_root.mkdir(parents=True, exist_ok=True)
|
|
3728
|
+
|
|
3729
|
+
if not source_root.exists():
|
|
3730
|
+
return str(target_root)
|
|
3731
|
+
|
|
3732
|
+
for child in source_root.iterdir():
|
|
3733
|
+
if child.name in {"logs", "session-state"}:
|
|
3734
|
+
continue
|
|
3735
|
+
|
|
3736
|
+
dest = target_root / child.name
|
|
3737
|
+
try:
|
|
3738
|
+
if child.is_dir():
|
|
3739
|
+
if child.name != "ide":
|
|
3740
|
+
continue
|
|
3741
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
3742
|
+
for sub in child.iterdir():
|
|
3743
|
+
if not sub.is_file() or sub.name.endswith(".lock"):
|
|
3744
|
+
continue
|
|
3745
|
+
shutil.copy2(sub, dest / sub.name)
|
|
3746
|
+
elif child.is_file():
|
|
3747
|
+
shutil.copy2(child, dest)
|
|
3748
|
+
except Exception as exc:
|
|
3749
|
+
logger.debug(
|
|
3750
|
+
"Copilot home mirror skipped %s -> %s: %s",
|
|
3751
|
+
child, dest, exc,
|
|
3752
|
+
)
|
|
3753
|
+
|
|
3754
|
+
return str(target_root)
|
|
3755
|
+
|
|
3607
3756
|
async def _run_copilot(
|
|
3608
3757
|
self, agent: DiscoveredAgent, prompt: str, cwd: Path, timeout: int, task_id: str,
|
|
3609
3758
|
on_chunk: Any = None,
|
|
@@ -3623,6 +3772,7 @@ class ProcessManager:
|
|
|
3623
3772
|
"""
|
|
3624
3773
|
env = os.environ.copy()
|
|
3625
3774
|
env["TERM"] = "dumb" # suppress TTY-detection that suspends the process
|
|
3775
|
+
env["COPILOT_HOME"] = self._prepare_copilot_home()
|
|
3626
3776
|
|
|
3627
3777
|
model_override = os.environ.get("FACTORY_COPILOT_MODEL")
|
|
3628
3778
|
reasoning = os.environ.get("FACTORY_COPILOT_REASONING", "medium")
|
|
@@ -6360,42 +6510,24 @@ class RuntimeDaemon:
|
|
|
6360
6510
|
# 5. Report completion
|
|
6361
6511
|
# For deliverables: allow up to 200K chars (full document); others: last 20K
|
|
6362
6512
|
max_content = 200000 if task_type == "deliverable_generate" else 20000
|
|
6363
|
-
|
|
6364
|
-
|
|
6365
|
-
|
|
6366
|
-
|
|
6367
|
-
|
|
6368
|
-
|
|
6369
|
-
|
|
6370
|
-
|
|
6371
|
-
|
|
6372
|
-
|
|
6373
|
-
|
|
6374
|
-
|
|
6375
|
-
|
|
6376
|
-
|
|
6377
|
-
|
|
6378
|
-
|
|
6379
|
-
|
|
6380
|
-
|
|
6381
|
-
# the entire output as that scenario's script.
|
|
6382
|
-
if not scripts and len(scenario_ids) == 1:
|
|
6383
|
-
scripts[scenario_ids[0]] = output_content.strip()
|
|
6384
|
-
|
|
6385
|
-
# Fallback: check workspace for test files named after scenario
|
|
6386
|
-
if not scripts:
|
|
6387
|
-
import glob as _glob
|
|
6388
|
-
for sid in scenario_ids:
|
|
6389
|
-
test_files = _glob.glob(
|
|
6390
|
-
str(workspace_path / "tests" / "**" / f"*{sid[:8]}*"),
|
|
6391
|
-
recursive=True,
|
|
6392
|
-
)
|
|
6393
|
-
if test_files:
|
|
6394
|
-
try:
|
|
6395
|
-
with open(test_files[0], "r") as f:
|
|
6396
|
-
scripts[sid] = f.read()
|
|
6397
|
-
except Exception:
|
|
6398
|
-
pass
|
|
6513
|
+
normalized_output, scenario_items = _normalize_ai_job_output(
|
|
6514
|
+
task_type=task_type,
|
|
6515
|
+
agent_id=agent.agent_id,
|
|
6516
|
+
raw_output=result.stdout or "",
|
|
6517
|
+
)
|
|
6518
|
+
output_content = normalized_output[-max_content:] if normalized_output else ""
|
|
6519
|
+
scripts = _extract_ai_job_scripts(
|
|
6520
|
+
output_text=normalized_output,
|
|
6521
|
+
scenario_ids=input_ctx.get("scenario_ids", []),
|
|
6522
|
+
workspace_path=workspace_path,
|
|
6523
|
+
)
|
|
6524
|
+
if task_type == "qa_scenario_generate_prompt":
|
|
6525
|
+
output_content, scenario_items = _prepare_ai_job_output(
|
|
6526
|
+
task_type=task_type,
|
|
6527
|
+
agent_id=agent.agent_id,
|
|
6528
|
+
raw_output=result.stdout or "",
|
|
6529
|
+
max_content=max_content,
|
|
6530
|
+
)
|
|
6399
6531
|
|
|
6400
6532
|
# Preserve all_agents_rate_limited so the server does NOT re-enqueue.
|
|
6401
6533
|
_failure_code = result.failure_code if result.failure_code else (
|
|
@@ -6416,6 +6548,8 @@ class RuntimeDaemon:
|
|
|
6416
6548
|
"error": result.error if result.status != "success" else "",
|
|
6417
6549
|
"failure_code": _failure_code,
|
|
6418
6550
|
}
|
|
6551
|
+
if scenario_items:
|
|
6552
|
+
complete_payload["output_result"]["items"] = scenario_items
|
|
6419
6553
|
|
|
6420
6554
|
await conn.client.post(
|
|
6421
6555
|
f"{reporter_url}/complete",
|
|
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
|