forgexa-cli 1.11.4__tar.gz → 1.11.5__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.4
3
+ Version: 1.11.5
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: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.11.4"
2
+ __version__ = "1.11.5"
@@ -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.11.4"
477
+ DAEMON_VERSION = "1.11.5"
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- "clone", "--single-branch", "--no-tags",
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
- output_content = (result.stdout or "")[-max_content:] if result.stdout else ""
6364
- scripts: dict = {}
6365
-
6366
- scenario_ids = input_ctx.get("scenario_ids", [])
6367
- if scenario_ids and output_content:
6368
- # Primary: extract scripts using structured SCRIPT_START/END markers
6369
- # inserted by poll_ai_jobs into the multi-scenario prompt.
6370
- import re as _re
6371
- for sid in scenario_ids:
6372
- pattern = (
6373
- r"##\s*SCRIPT_START::" + _re.escape(sid)
6374
- + r"\s*\n(.*?)\n##\s*SCRIPT_END::" + _re.escape(sid)
6375
- )
6376
- m = _re.search(pattern, output_content, _re.DOTALL)
6377
- if m:
6378
- scripts[sid] = m.group(1).strip()
6379
-
6380
- # Fallback: if no markers found but only one scenario, treat
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",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.11.4
3
+ Version: 1.11.5
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: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.11.4"
3
+ version = "1.11.5"
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 = { text = "MIT" }
File without changes
File without changes