forgexa-cli 1.48.1__tar.gz → 1.48.2__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.
Files changed (28) hide show
  1. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/PKG-INFO +1 -1
  2. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/__init__.py +1 -1
  3. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/daemon.py +290 -15
  4. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli.egg-info/PKG-INFO +1 -1
  5. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/pyproject.toml +1 -1
  6. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/README.md +0 -0
  7. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/_build_config.py +0 -0
  8. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/_local_bind.py +0 -0
  9. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/agent_core.py +0 -0
  10. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/autoupgrade.py +0 -0
  11. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/main.py +0 -0
  12. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/py.typed +0 -0
  13. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli/runtime_evidence.py +0 -0
  14. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli.egg-info/SOURCES.txt +0 -0
  15. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli.egg-info/dependency_links.txt +0 -0
  16. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli.egg-info/entry_points.txt +0 -0
  17. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli.egg-info/requires.txt +0 -0
  18. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/forgexa_cli.egg-info/top_level.txt +0 -0
  19. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/setup.cfg +0 -0
  20. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_auth_and_runtime_commands.py +0 -0
  21. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_autoupgrade.py +0 -0
  22. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_check_command.py +0 -0
  23. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_expiry_warnings_and_revoke.py +0 -0
  24. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_local_bind_commands.py +0 -0
  25. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_runtime_credentials.py +0 -0
  26. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_session_credentials.py +0 -0
  27. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_silent_install.py +0 -0
  28. {forgexa_cli-1.48.1 → forgexa_cli-1.48.2}/tests/test_upgrade_observability.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.48.1
3
+ Version: 1.48.2
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-Expression: MIT
@@ -1,2 +1,2 @@
1
1
  """forgexa-cli — Forgexa command-line client."""
2
- __version__ = "1.48.1"
2
+ __version__ = "1.48.2"
@@ -1120,7 +1120,7 @@ except (ImportError, ModuleNotFoundError):
1120
1120
  # DAEMON_VERSION is the protocol/logic version of the daemon code.
1121
1121
  # Kept in sync with pyproject.toml version via bump-version.sh.
1122
1122
  # CLIENT_TYPE identifies which packaging/distribution this daemon runs in.
1123
- DAEMON_VERSION = "1.48.1"
1123
+ DAEMON_VERSION = "1.48.2"
1124
1124
 
1125
1125
 
1126
1126
  def _detect_client_type() -> str:
@@ -5444,18 +5444,22 @@ class ProcessManager:
5444
5444
  return "unable to run"
5445
5445
 
5446
5446
  @staticmethod
5447
- def is_silent_idle_timeout(result: "TaskResult") -> bool:
5448
- """Return whether an agent was killed before producing any evidence.
5449
-
5450
- A timeout after output or workspace changes can represent completed work
5451
- and must be handled by the normal recovery path. A completely silent
5452
- timeout instead means this specific CLI is stuck before it can begin,
5453
- so one alternate agent is safe to try.
5447
+ def is_idle_timeout_without_work(result: "TaskResult") -> bool:
5448
+ """Return whether an idle-killed agent left no work worth preserving.
5449
+
5450
+ The deciding evidence is workspace output (``files_changed``), NOT
5451
+ stdout/stderr chatter: an agent can emit an intro sentence and a
5452
+ todowrite event and then stall forever on a hung model request
5453
+ (SI-1453: OpenCode produced 2 output lines in 39 minutes, zero file
5454
+ changes). Streamed text is conversation, not work, so it must not
5455
+ block recovery. When no files were changed, trying an alternate
5456
+ agent is safe: the pre-fallback guard in the task runner re-checks
5457
+ the workspace (uncommitted AND committed changes since
5458
+ node_before_sha) before switching agents, and timeouts that occurred
5459
+ after real work still go through the normal recovery path.
5454
5460
  """
5455
5461
  return (
5456
5462
  result.failure_code == "agent_idle_timeout"
5457
- and not result.stdout.strip()
5458
- and not result.stderr.strip()
5459
5463
  and not result.files_changed
5460
5464
  )
5461
5465
 
@@ -8955,6 +8959,261 @@ def _validate_test_evidence(
8955
8959
  return issues
8956
8960
 
8957
8961
 
8962
+ _QA_TEST_LEVELS = frozenset({"unit", "integration", "e2e"})
8963
+
8964
+
8965
+ def _parse_frozen_qa_test_level_policy(
8966
+ snapshot: object,
8967
+ ) -> tuple[dict[str, Any] | None, list[str]]:
8968
+ """Parse a server-owned policy snapshot without backend-package imports.
8969
+
8970
+ The daemon also runs standalone in CLI/Desktop bundles, so this mirrors the
8971
+ server contract with stdlib-only validation rather than importing a backend
8972
+ service. A missing snapshot is a historical task and intentionally keeps
8973
+ legacy validation behavior.
8974
+ """
8975
+ if snapshot is None:
8976
+ return None, []
8977
+ if not isinstance(snapshot, dict):
8978
+ return None, [
8979
+ "qa_policy.configuration_invalid: frozen QA test-level policy snapshot must be an object."
8980
+ ]
8981
+ policy = snapshot.get("policy")
8982
+ if not isinstance(policy, dict):
8983
+ return None, [
8984
+ "qa_policy.configuration_invalid: frozen QA test-level policy snapshot is missing its policy."
8985
+ ]
8986
+ expected_fingerprint = hashlib.sha256(
8987
+ json.dumps(policy, sort_keys=True, separators=(",", ":")).encode("utf-8")
8988
+ ).hexdigest()
8989
+ if snapshot.get("fingerprint") != expected_fingerprint:
8990
+ return None, [
8991
+ "qa_policy.configuration_invalid: frozen QA test-level policy fingerprint does not match its payload."
8992
+ ]
8993
+ if set(policy) != {"schema_version", "enforcement", "levels"}:
8994
+ return None, [
8995
+ "qa_policy.configuration_invalid: frozen QA test-level policy has unexpected fields."
8996
+ ]
8997
+ if type(policy.get("schema_version")) is not int or policy["schema_version"] != 1:
8998
+ return None, [
8999
+ "qa_policy.configuration_invalid: frozen QA test-level policy schema_version is unsupported."
9000
+ ]
9001
+ enforcement = policy.get("enforcement")
9002
+ if enforcement not in {"legacy", "enforce"}:
9003
+ return None, [
9004
+ "qa_policy.configuration_invalid: frozen QA test-level policy enforcement is invalid."
9005
+ ]
9006
+ levels = policy.get("levels")
9007
+ if not isinstance(levels, dict) or set(levels) != _QA_TEST_LEVELS:
9008
+ return None, [
9009
+ "qa_policy.configuration_invalid: frozen QA test-level policy levels are invalid."
9010
+ ]
9011
+
9012
+ normalized_levels: dict[str, dict[str, bool]] = {}
9013
+ for level in _QA_TEST_LEVELS:
9014
+ config = levels.get(level)
9015
+ if not isinstance(config, dict) or set(config) != {"enabled", "workflow_auto_run"}:
9016
+ return None, [
9017
+ "qa_policy.configuration_invalid: frozen QA test-level policy level configuration is invalid."
9018
+ ]
9019
+ enabled = config.get("enabled")
9020
+ auto_run = config.get("workflow_auto_run")
9021
+ if type(enabled) is not bool or type(auto_run) is not bool:
9022
+ return None, [
9023
+ "qa_policy.configuration_invalid: frozen QA test-level policy switches must be boolean."
9024
+ ]
9025
+ if auto_run and not enabled:
9026
+ return None, [
9027
+ "qa_policy.configuration_invalid: workflow_auto_run requires an enabled test level."
9028
+ ]
9029
+ normalized_levels[level] = {
9030
+ "enabled": enabled,
9031
+ "workflow_auto_run": auto_run,
9032
+ }
9033
+ return {
9034
+ "enforcement": enforcement,
9035
+ "fingerprint": expected_fingerprint,
9036
+ "levels": normalized_levels,
9037
+ }, []
9038
+
9039
+
9040
+ def _validate_enforced_qa_test_level_artifacts(
9041
+ policy: dict[str, Any],
9042
+ test_intent_data: object,
9043
+ test_cases_data: object,
9044
+ workspace_path: Path,
9045
+ ) -> list[str]:
9046
+ """Validate a testing node's artifacts against its frozen policy snapshot."""
9047
+ if policy.get("enforcement") != "enforce":
9048
+ return []
9049
+
9050
+ issues: list[str] = []
9051
+ if not isinstance(test_intent_data, dict):
9052
+ return [
9053
+ "qa_policy.test_intent_missing: enforced QA policy requires test-intent.json."
9054
+ ]
9055
+ declared_fingerprint = test_intent_data.get("qa_test_level_policy_fingerprint")
9056
+ if not isinstance(declared_fingerprint, str) or not declared_fingerprint.strip():
9057
+ issues.append(
9058
+ "qa_policy.fingerprint_missing: test-intent.json must echo the frozen QA policy fingerprint."
9059
+ )
9060
+ elif declared_fingerprint.strip() != policy["fingerprint"]:
9061
+ issues.append(
9062
+ "qa_policy.fingerprint_mismatch: test-intent.json does not match the frozen QA policy."
9063
+ )
9064
+
9065
+ raw_intents = test_intent_data.get("intents")
9066
+ if not isinstance(raw_intents, list):
9067
+ return issues + [
9068
+ "qa_policy.test_intent_invalid: test-intent.json intents must be an array."
9069
+ ]
9070
+
9071
+ automated_intent_ids: dict[str, set[str]] = {
9072
+ level: set() for level in _QA_TEST_LEVELS
9073
+ }
9074
+ manual_intent_reasons: dict[str, str] = {}
9075
+ for index, raw_intent in enumerate(raw_intents[:200]):
9076
+ if not isinstance(raw_intent, dict):
9077
+ issues.append(f"qa_policy.test_intent_invalid: intent {index} must be an object.")
9078
+ continue
9079
+ intent_id = raw_intent.get("id") or raw_intent.get("test_id") or raw_intent.get("intent_id")
9080
+ if not isinstance(intent_id, str) or not intent_id.strip():
9081
+ issues.append(f"qa_policy.test_intent_invalid: intent {index} is missing id.")
9082
+ continue
9083
+ intent_id = intent_id.strip()
9084
+ raw_target = raw_intent.get("automation_target")
9085
+ target = raw_target.strip().lower() if isinstance(raw_target, str) else ""
9086
+ if target == "manual":
9087
+ reason = raw_intent.get("manual_reason_code")
9088
+ if not isinstance(reason, str) or not reason.strip():
9089
+ issues.append(
9090
+ f"qa_policy.manual_reason_code_missing: manual intent {intent_id} must define manual_reason_code."
9091
+ )
9092
+ else:
9093
+ manual_intent_reasons[intent_id] = reason.strip()
9094
+ elif target not in _QA_TEST_LEVELS:
9095
+ issues.append(
9096
+ f"qa_policy.test_level_unclassified: intent {intent_id} has unsupported automation_target {target!r}."
9097
+ )
9098
+ elif not policy["levels"][target]["enabled"]:
9099
+ issues.append(
9100
+ f"qa_policy.level_disabled: intent {intent_id} uses disabled automation_target {target!r}."
9101
+ )
9102
+ else:
9103
+ automated_intent_ids[target].add(intent_id)
9104
+
9105
+ if not isinstance(test_cases_data, dict):
9106
+ return issues + [
9107
+ "qa_policy.test_cases_missing: enforced QA policy requires test-cases.json."
9108
+ ]
9109
+ raw_cases = test_cases_data.get("test_cases")
9110
+ if not isinstance(raw_cases, list):
9111
+ return issues + [
9112
+ "qa_policy.test_cases_invalid: test-cases.json test_cases must be an array."
9113
+ ]
9114
+
9115
+ covered_automated_intents: dict[str, set[str]] = {
9116
+ level: set() for level in _QA_TEST_LEVELS
9117
+ }
9118
+ covered_manual_intents: set[str] = set()
9119
+ for index, raw_case in enumerate(raw_cases[:200]):
9120
+ if not isinstance(raw_case, dict):
9121
+ issues.append(f"qa_policy.test_case_invalid: test case {index} must be an object.")
9122
+ continue
9123
+ case_id = raw_case.get("id")
9124
+ case_label = str(case_id).strip() if case_id else f"index {index}"
9125
+ raw_type = raw_case.get("test_type")
9126
+ case_type = raw_type.strip().lower() if isinstance(raw_type, str) else ""
9127
+ intent_id = raw_case.get("intent_id") or raw_case.get("test_intent_id")
9128
+ intent_id = intent_id.strip() if isinstance(intent_id, str) else ""
9129
+
9130
+ if case_type == "manual":
9131
+ if intent_id not in manual_intent_reasons:
9132
+ issues.append(
9133
+ f"qa_policy.manual_case_intent_mismatch: manual test case {case_label} must reference a manual intent."
9134
+ )
9135
+ else:
9136
+ covered_manual_intents.add(intent_id)
9137
+ reason = raw_case.get("manual_reason_code")
9138
+ if not isinstance(reason, str) or reason.strip() != manual_intent_reasons[intent_id]:
9139
+ issues.append(
9140
+ f"qa_policy.manual_reason_code_mismatch: manual test case {case_label} must preserve its intent reason."
9141
+ )
9142
+ if not isinstance(raw_case.get("acceptance_criteria_refs"), list) or not raw_case["acceptance_criteria_refs"]:
9143
+ issues.append(
9144
+ f"qa_policy.manual_case_missing_acceptance_refs: manual test case {case_label} must list acceptance_criteria_refs."
9145
+ )
9146
+ if not isinstance(raw_case.get("steps"), list) or not raw_case["steps"]:
9147
+ issues.append(
9148
+ f"qa_policy.manual_case_missing_steps: manual test case {case_label} must define executable steps."
9149
+ )
9150
+ if raw_case.get("script_path") or str(raw_case.get("script_content") or "").strip():
9151
+ issues.append(
9152
+ f"qa_policy.manual_case_has_script: manual test case {case_label} must not define a script."
9153
+ )
9154
+ continue
9155
+
9156
+ if case_type not in _QA_TEST_LEVELS:
9157
+ issues.append(
9158
+ f"qa_policy.test_level_unclassified: test case {case_label} has unsupported test_type {case_type!r}."
9159
+ )
9160
+ continue
9161
+ if not policy["levels"][case_type]["enabled"]:
9162
+ issues.append(
9163
+ f"qa_policy.level_disabled: test case {case_label} uses disabled test_type {case_type!r}."
9164
+ )
9165
+ continue
9166
+ if intent_id:
9167
+ covered_automated_intents[case_type].add(intent_id)
9168
+
9169
+ runner_family = raw_case.get("runner_family")
9170
+ runner = runner_family.strip().lower() if isinstance(runner_family, str) else ""
9171
+ if not runner:
9172
+ issues.append(
9173
+ f"qa_policy.runner_family_missing: automated test case {case_label} must define runner_family."
9174
+ )
9175
+ elif (case_type == "e2e" and runner != "playwright") or (
9176
+ case_type in {"unit", "integration"} and runner == "playwright"
9177
+ ):
9178
+ issues.append(
9179
+ f"qa_policy.runner_level_mismatch: test case {case_label} runner_family {runner!r} conflicts with test_type {case_type!r}."
9180
+ )
9181
+
9182
+ script_content = raw_case.get("script_content")
9183
+ if not isinstance(script_content, str) or not script_content.strip():
9184
+ issues.append(
9185
+ f"qa_policy.script_content_missing: automated test case {case_label} must define non-empty script_content."
9186
+ )
9187
+ script_path = raw_case.get("script_path")
9188
+ if not isinstance(script_path, str) or not script_path.strip():
9189
+ issues.append(
9190
+ f"qa_policy.script_path_missing: automated test case {case_label} must define script_path."
9191
+ )
9192
+ else:
9193
+ candidate_path = Path(script_path)
9194
+ if candidate_path.is_absolute() or ".." in candidate_path.parts:
9195
+ issues.append(
9196
+ f"qa_policy.script_path_invalid: automated test case {case_label} script_path must stay inside the workspace."
9197
+ )
9198
+ elif not (workspace_path / candidate_path).is_file():
9199
+ issues.append(
9200
+ f"qa_policy.script_path_missing: automated test case {case_label} script_path '{script_path}' does not exist on disk."
9201
+ )
9202
+
9203
+ for level, intent_ids in automated_intent_ids.items():
9204
+ missing = sorted(intent_ids - covered_automated_intents[level])
9205
+ if missing:
9206
+ issues.append(
9207
+ f"qa_policy.test_case_missing: test-cases.json lacks {level} test cases for intents {missing}."
9208
+ )
9209
+ missing_manual = sorted(set(manual_intent_reasons) - covered_manual_intents)
9210
+ if missing_manual:
9211
+ issues.append(
9212
+ f"qa_policy.manual_case_missing: test-cases.json lacks manual test cases for intents {missing_manual}."
9213
+ )
9214
+ return issues
9215
+
9216
+
8958
9217
  def _configured_daemon_api_token() -> str:
8959
9218
  token = str(settings.DAEMON_API_TOKEN or "").strip()
8960
9219
  if token.startswith("pat_"):
@@ -10254,7 +10513,7 @@ class RuntimeDaemon:
10254
10513
 
10255
10514
  tried_agents.add(agent.agent_id)
10256
10515
 
10257
- # ── Agent fallback: try another CLI after a backend failure or a fully silent hang ──
10516
+ # ── Agent fallback: try another CLI after a backend failure or a work-free hang ──
10258
10517
  # Guard: if the agent already produced file changes in the workspace, it DID
10259
10518
  # meaningful work — don't trigger fallback even if it crashed after completing.
10260
10519
  # Let the recovery logic (step 4.1) handle non-zero exit with committed work.
@@ -10268,12 +10527,12 @@ class RuntimeDaemon:
10268
10527
  else ProcessManager.agent_failure_kind(result)
10269
10528
  )
10270
10529
  is_agent_fallback_eligible = agent_failure_kind is not None
10271
- is_silent_idle_timeout = self.process_manager.is_silent_idle_timeout(result)
10530
+ is_idle_timeout_without_work = self.process_manager.is_idle_timeout_without_work(result)
10272
10531
  fallback_reason = (
10273
10532
  agent_failure_kind
10274
10533
  if is_agent_fallback_eligible
10275
10534
  else "agent_idle_timeout"
10276
- if is_silent_idle_timeout
10535
+ if is_idle_timeout_without_work
10277
10536
  else ""
10278
10537
  )
10279
10538
  fallback_label = (
@@ -11396,6 +11655,10 @@ class RuntimeDaemon:
11396
11655
 
11397
11656
  if not _skip_test_artifacts:
11398
11657
  _input = task.input_data or {}
11658
+ _qa_policy, _qa_policy_issues = _parse_frozen_qa_test_level_policy(
11659
+ _input.get("qa_test_level_policy_snapshot")
11660
+ )
11661
+ issues.extend(_qa_policy_issues)
11399
11662
  doc_dir = (
11400
11663
  _input.get("output_dir")
11401
11664
  or _input.get("context", {}).get("output_dir")
@@ -11411,6 +11674,8 @@ class RuntimeDaemon:
11411
11674
  # intents gets the same E2E chain validation as feature/improvement.
11412
11675
  _e2e_intent_ids: set[str] = set()
11413
11676
  _e2e_case_ids: list[str] = []
11677
+ _ti_data: dict[str, Any] | None = None
11678
+ _tc_data: dict[str, Any] | None = None
11414
11679
  _analysis_dir = (
11415
11680
  _input.get("analysis_output_dir")
11416
11681
  or (_input.get("context") or {}).get("analysis_output_dir")
@@ -11424,6 +11689,8 @@ class RuntimeDaemon:
11424
11689
  _ti_path.read_text(encoding="utf-8")
11425
11690
  )
11426
11691
  for _ti in _ti_data.get("intents", []):
11692
+ if not isinstance(_ti, dict):
11693
+ continue
11427
11694
  if (_ti.get("automation_target") or "").lower() == "e2e":
11428
11695
  _ti_id = (
11429
11696
  _ti.get("id")
@@ -11445,8 +11712,8 @@ class RuntimeDaemon:
11445
11712
  tc_path = base / "test-cases.json"
11446
11713
  if tc_path.exists():
11447
11714
  try:
11448
- tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
11449
- cases = tc_data.get("test_cases", [])
11715
+ _tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
11716
+ cases = _tc_data.get("test_cases", [])
11450
11717
  _e2e_case_ids = [
11451
11718
  str(_tc.get("id"))
11452
11719
  for _tc in cases
@@ -11526,6 +11793,14 @@ class RuntimeDaemon:
11526
11793
  elif _requires_structured_artifacts or _requires_e2e_artifacts:
11527
11794
  issues.append(f"test-cases.json not found in {doc_dir or 'workspace root'}")
11528
11795
 
11796
+ if _qa_policy is not None:
11797
+ issues.extend(_validate_enforced_qa_test_level_artifacts(
11798
+ _qa_policy,
11799
+ _ti_data,
11800
+ _tc_data,
11801
+ workspace_path,
11802
+ ))
11803
+
11529
11804
  # --- coverage-matrix.json validation ---
11530
11805
  cm_path = base / "coverage-matrix.json"
11531
11806
  if cm_path.exists():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgexa-cli
3
- Version: 1.48.1
3
+ Version: 1.48.2
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-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgexa-cli"
3
- version = "1.48.1"
3
+ version = "1.48.2"
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 = "MIT"
File without changes
File without changes