forgexa-cli 1.48.1__tar.gz → 1.48.3__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.48.1 → forgexa_cli-1.48.3}/PKG-INFO +1 -1
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/__init__.py +1 -1
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/daemon.py +621 -16
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/PKG-INFO +1 -1
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/pyproject.toml +1 -1
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/README.md +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/_build_config.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/_local_bind.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/agent_core.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/autoupgrade.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/main.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/py.typed +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli/runtime_evidence.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/SOURCES.txt +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/dependency_links.txt +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/entry_points.txt +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/requires.txt +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/forgexa_cli.egg-info/top_level.txt +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/setup.cfg +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_auth_and_runtime_commands.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_autoupgrade.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_check_command.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_expiry_warnings_and_revoke.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_local_bind_commands.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_runtime_credentials.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_session_credentials.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_silent_install.py +0 -0
- {forgexa_cli-1.48.1 → forgexa_cli-1.48.3}/tests/test_upgrade_observability.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""forgexa-cli — Forgexa command-line client."""
|
|
2
|
-
__version__ = "1.48.
|
|
2
|
+
__version__ = "1.48.3"
|
|
@@ -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.
|
|
1123
|
+
DAEMON_VERSION = "1.48.3"
|
|
1124
1124
|
|
|
1125
1125
|
|
|
1126
1126
|
def _detect_client_type() -> str:
|
|
@@ -5189,6 +5189,10 @@ class _IdleTimeoutError(asyncio.TimeoutError):
|
|
|
5189
5189
|
self.stderr = ""
|
|
5190
5190
|
|
|
5191
5191
|
|
|
5192
|
+
class _TaskAttachmentDownloadError(RuntimeError):
|
|
5193
|
+
"""Raised when a required task attachment cannot be staged safely."""
|
|
5194
|
+
|
|
5195
|
+
|
|
5192
5196
|
def _workspace_has_recent_activity(
|
|
5193
5197
|
workspace_path: "Path", since_monotonic: float, max_depth: int = 4
|
|
5194
5198
|
) -> bool:
|
|
@@ -5444,18 +5448,22 @@ class ProcessManager:
|
|
|
5444
5448
|
return "unable to run"
|
|
5445
5449
|
|
|
5446
5450
|
@staticmethod
|
|
5447
|
-
def
|
|
5448
|
-
"""Return whether an agent
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5452
|
-
|
|
5453
|
-
|
|
5451
|
+
def is_idle_timeout_without_work(result: "TaskResult") -> bool:
|
|
5452
|
+
"""Return whether an idle-killed agent left no work worth preserving.
|
|
5453
|
+
|
|
5454
|
+
The deciding evidence is workspace output (``files_changed``), NOT
|
|
5455
|
+
stdout/stderr chatter: an agent can emit an intro sentence and a
|
|
5456
|
+
todowrite event and then stall forever on a hung model request
|
|
5457
|
+
(SI-1453: OpenCode produced 2 output lines in 39 minutes, zero file
|
|
5458
|
+
changes). Streamed text is conversation, not work, so it must not
|
|
5459
|
+
block recovery. When no files were changed, trying an alternate
|
|
5460
|
+
agent is safe: the pre-fallback guard in the task runner re-checks
|
|
5461
|
+
the workspace (uncommitted AND committed changes since
|
|
5462
|
+
node_before_sha) before switching agents, and timeouts that occurred
|
|
5463
|
+
after real work still go through the normal recovery path.
|
|
5454
5464
|
"""
|
|
5455
5465
|
return (
|
|
5456
5466
|
result.failure_code == "agent_idle_timeout"
|
|
5457
|
-
and not result.stdout.strip()
|
|
5458
|
-
and not result.stderr.strip()
|
|
5459
5467
|
and not result.files_changed
|
|
5460
5468
|
)
|
|
5461
5469
|
|
|
@@ -8220,6 +8228,7 @@ class ServerConnection:
|
|
|
8220
8228
|
"platform": platform.platform(),
|
|
8221
8229
|
"python_version": platform.python_version(),
|
|
8222
8230
|
"local_workspace_bindings": {"protocol_version": 3},
|
|
8231
|
+
"task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
|
|
8223
8232
|
},
|
|
8224
8233
|
"server_url": self.server_url,
|
|
8225
8234
|
"expires_in_days": 90,
|
|
@@ -8619,7 +8628,7 @@ class ServerConnection:
|
|
|
8619
8628
|
"python_version": platform.python_version(),
|
|
8620
8629
|
"local_workspace_bindings": {"protocol_version": 3},
|
|
8621
8630
|
"local_evidence": {"protocol_version": 1, "structured_summary": True},
|
|
8622
|
-
"
|
|
8631
|
+
"task_attachment_staging": {"protocol_version": 1, "runtime_proxy": True},
|
|
8623
8632
|
},
|
|
8624
8633
|
"server_url": self.server_url,
|
|
8625
8634
|
}
|
|
@@ -8955,6 +8964,261 @@ def _validate_test_evidence(
|
|
|
8955
8964
|
return issues
|
|
8956
8965
|
|
|
8957
8966
|
|
|
8967
|
+
_QA_TEST_LEVELS = frozenset({"unit", "integration", "e2e"})
|
|
8968
|
+
|
|
8969
|
+
|
|
8970
|
+
def _parse_frozen_qa_test_level_policy(
|
|
8971
|
+
snapshot: object,
|
|
8972
|
+
) -> tuple[dict[str, Any] | None, list[str]]:
|
|
8973
|
+
"""Parse a server-owned policy snapshot without backend-package imports.
|
|
8974
|
+
|
|
8975
|
+
The daemon also runs standalone in CLI/Desktop bundles, so this mirrors the
|
|
8976
|
+
server contract with stdlib-only validation rather than importing a backend
|
|
8977
|
+
service. A missing snapshot is a historical task and intentionally keeps
|
|
8978
|
+
legacy validation behavior.
|
|
8979
|
+
"""
|
|
8980
|
+
if snapshot is None:
|
|
8981
|
+
return None, []
|
|
8982
|
+
if not isinstance(snapshot, dict):
|
|
8983
|
+
return None, [
|
|
8984
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy snapshot must be an object."
|
|
8985
|
+
]
|
|
8986
|
+
policy = snapshot.get("policy")
|
|
8987
|
+
if not isinstance(policy, dict):
|
|
8988
|
+
return None, [
|
|
8989
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy snapshot is missing its policy."
|
|
8990
|
+
]
|
|
8991
|
+
expected_fingerprint = hashlib.sha256(
|
|
8992
|
+
json.dumps(policy, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
8993
|
+
).hexdigest()
|
|
8994
|
+
if snapshot.get("fingerprint") != expected_fingerprint:
|
|
8995
|
+
return None, [
|
|
8996
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy fingerprint does not match its payload."
|
|
8997
|
+
]
|
|
8998
|
+
if set(policy) != {"schema_version", "enforcement", "levels"}:
|
|
8999
|
+
return None, [
|
|
9000
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy has unexpected fields."
|
|
9001
|
+
]
|
|
9002
|
+
if type(policy.get("schema_version")) is not int or policy["schema_version"] != 1:
|
|
9003
|
+
return None, [
|
|
9004
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy schema_version is unsupported."
|
|
9005
|
+
]
|
|
9006
|
+
enforcement = policy.get("enforcement")
|
|
9007
|
+
if enforcement not in {"legacy", "enforce"}:
|
|
9008
|
+
return None, [
|
|
9009
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy enforcement is invalid."
|
|
9010
|
+
]
|
|
9011
|
+
levels = policy.get("levels")
|
|
9012
|
+
if not isinstance(levels, dict) or set(levels) != _QA_TEST_LEVELS:
|
|
9013
|
+
return None, [
|
|
9014
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy levels are invalid."
|
|
9015
|
+
]
|
|
9016
|
+
|
|
9017
|
+
normalized_levels: dict[str, dict[str, bool]] = {}
|
|
9018
|
+
for level in _QA_TEST_LEVELS:
|
|
9019
|
+
config = levels.get(level)
|
|
9020
|
+
if not isinstance(config, dict) or set(config) != {"enabled", "workflow_auto_run"}:
|
|
9021
|
+
return None, [
|
|
9022
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy level configuration is invalid."
|
|
9023
|
+
]
|
|
9024
|
+
enabled = config.get("enabled")
|
|
9025
|
+
auto_run = config.get("workflow_auto_run")
|
|
9026
|
+
if type(enabled) is not bool or type(auto_run) is not bool:
|
|
9027
|
+
return None, [
|
|
9028
|
+
"qa_policy.configuration_invalid: frozen QA test-level policy switches must be boolean."
|
|
9029
|
+
]
|
|
9030
|
+
if auto_run and not enabled:
|
|
9031
|
+
return None, [
|
|
9032
|
+
"qa_policy.configuration_invalid: workflow_auto_run requires an enabled test level."
|
|
9033
|
+
]
|
|
9034
|
+
normalized_levels[level] = {
|
|
9035
|
+
"enabled": enabled,
|
|
9036
|
+
"workflow_auto_run": auto_run,
|
|
9037
|
+
}
|
|
9038
|
+
return {
|
|
9039
|
+
"enforcement": enforcement,
|
|
9040
|
+
"fingerprint": expected_fingerprint,
|
|
9041
|
+
"levels": normalized_levels,
|
|
9042
|
+
}, []
|
|
9043
|
+
|
|
9044
|
+
|
|
9045
|
+
def _validate_enforced_qa_test_level_artifacts(
|
|
9046
|
+
policy: dict[str, Any],
|
|
9047
|
+
test_intent_data: object,
|
|
9048
|
+
test_cases_data: object,
|
|
9049
|
+
workspace_path: Path,
|
|
9050
|
+
) -> list[str]:
|
|
9051
|
+
"""Validate a testing node's artifacts against its frozen policy snapshot."""
|
|
9052
|
+
if policy.get("enforcement") != "enforce":
|
|
9053
|
+
return []
|
|
9054
|
+
|
|
9055
|
+
issues: list[str] = []
|
|
9056
|
+
if not isinstance(test_intent_data, dict):
|
|
9057
|
+
return [
|
|
9058
|
+
"qa_policy.test_intent_missing: enforced QA policy requires test-intent.json."
|
|
9059
|
+
]
|
|
9060
|
+
declared_fingerprint = test_intent_data.get("qa_test_level_policy_fingerprint")
|
|
9061
|
+
if not isinstance(declared_fingerprint, str) or not declared_fingerprint.strip():
|
|
9062
|
+
issues.append(
|
|
9063
|
+
"qa_policy.fingerprint_missing: test-intent.json must echo the frozen QA policy fingerprint."
|
|
9064
|
+
)
|
|
9065
|
+
elif declared_fingerprint.strip() != policy["fingerprint"]:
|
|
9066
|
+
issues.append(
|
|
9067
|
+
"qa_policy.fingerprint_mismatch: test-intent.json does not match the frozen QA policy."
|
|
9068
|
+
)
|
|
9069
|
+
|
|
9070
|
+
raw_intents = test_intent_data.get("intents")
|
|
9071
|
+
if not isinstance(raw_intents, list):
|
|
9072
|
+
return issues + [
|
|
9073
|
+
"qa_policy.test_intent_invalid: test-intent.json intents must be an array."
|
|
9074
|
+
]
|
|
9075
|
+
|
|
9076
|
+
automated_intent_ids: dict[str, set[str]] = {
|
|
9077
|
+
level: set() for level in _QA_TEST_LEVELS
|
|
9078
|
+
}
|
|
9079
|
+
manual_intent_reasons: dict[str, str] = {}
|
|
9080
|
+
for index, raw_intent in enumerate(raw_intents[:200]):
|
|
9081
|
+
if not isinstance(raw_intent, dict):
|
|
9082
|
+
issues.append(f"qa_policy.test_intent_invalid: intent {index} must be an object.")
|
|
9083
|
+
continue
|
|
9084
|
+
intent_id = raw_intent.get("id") or raw_intent.get("test_id") or raw_intent.get("intent_id")
|
|
9085
|
+
if not isinstance(intent_id, str) or not intent_id.strip():
|
|
9086
|
+
issues.append(f"qa_policy.test_intent_invalid: intent {index} is missing id.")
|
|
9087
|
+
continue
|
|
9088
|
+
intent_id = intent_id.strip()
|
|
9089
|
+
raw_target = raw_intent.get("automation_target")
|
|
9090
|
+
target = raw_target.strip().lower() if isinstance(raw_target, str) else ""
|
|
9091
|
+
if target == "manual":
|
|
9092
|
+
reason = raw_intent.get("manual_reason_code")
|
|
9093
|
+
if not isinstance(reason, str) or not reason.strip():
|
|
9094
|
+
issues.append(
|
|
9095
|
+
f"qa_policy.manual_reason_code_missing: manual intent {intent_id} must define manual_reason_code."
|
|
9096
|
+
)
|
|
9097
|
+
else:
|
|
9098
|
+
manual_intent_reasons[intent_id] = reason.strip()
|
|
9099
|
+
elif target not in _QA_TEST_LEVELS:
|
|
9100
|
+
issues.append(
|
|
9101
|
+
f"qa_policy.test_level_unclassified: intent {intent_id} has unsupported automation_target {target!r}."
|
|
9102
|
+
)
|
|
9103
|
+
elif not policy["levels"][target]["enabled"]:
|
|
9104
|
+
issues.append(
|
|
9105
|
+
f"qa_policy.level_disabled: intent {intent_id} uses disabled automation_target {target!r}."
|
|
9106
|
+
)
|
|
9107
|
+
else:
|
|
9108
|
+
automated_intent_ids[target].add(intent_id)
|
|
9109
|
+
|
|
9110
|
+
if not isinstance(test_cases_data, dict):
|
|
9111
|
+
return issues + [
|
|
9112
|
+
"qa_policy.test_cases_missing: enforced QA policy requires test-cases.json."
|
|
9113
|
+
]
|
|
9114
|
+
raw_cases = test_cases_data.get("test_cases")
|
|
9115
|
+
if not isinstance(raw_cases, list):
|
|
9116
|
+
return issues + [
|
|
9117
|
+
"qa_policy.test_cases_invalid: test-cases.json test_cases must be an array."
|
|
9118
|
+
]
|
|
9119
|
+
|
|
9120
|
+
covered_automated_intents: dict[str, set[str]] = {
|
|
9121
|
+
level: set() for level in _QA_TEST_LEVELS
|
|
9122
|
+
}
|
|
9123
|
+
covered_manual_intents: set[str] = set()
|
|
9124
|
+
for index, raw_case in enumerate(raw_cases[:200]):
|
|
9125
|
+
if not isinstance(raw_case, dict):
|
|
9126
|
+
issues.append(f"qa_policy.test_case_invalid: test case {index} must be an object.")
|
|
9127
|
+
continue
|
|
9128
|
+
case_id = raw_case.get("id")
|
|
9129
|
+
case_label = str(case_id).strip() if case_id else f"index {index}"
|
|
9130
|
+
raw_type = raw_case.get("test_type")
|
|
9131
|
+
case_type = raw_type.strip().lower() if isinstance(raw_type, str) else ""
|
|
9132
|
+
intent_id = raw_case.get("intent_id") or raw_case.get("test_intent_id")
|
|
9133
|
+
intent_id = intent_id.strip() if isinstance(intent_id, str) else ""
|
|
9134
|
+
|
|
9135
|
+
if case_type == "manual":
|
|
9136
|
+
if intent_id not in manual_intent_reasons:
|
|
9137
|
+
issues.append(
|
|
9138
|
+
f"qa_policy.manual_case_intent_mismatch: manual test case {case_label} must reference a manual intent."
|
|
9139
|
+
)
|
|
9140
|
+
else:
|
|
9141
|
+
covered_manual_intents.add(intent_id)
|
|
9142
|
+
reason = raw_case.get("manual_reason_code")
|
|
9143
|
+
if not isinstance(reason, str) or reason.strip() != manual_intent_reasons[intent_id]:
|
|
9144
|
+
issues.append(
|
|
9145
|
+
f"qa_policy.manual_reason_code_mismatch: manual test case {case_label} must preserve its intent reason."
|
|
9146
|
+
)
|
|
9147
|
+
if not isinstance(raw_case.get("acceptance_criteria_refs"), list) or not raw_case["acceptance_criteria_refs"]:
|
|
9148
|
+
issues.append(
|
|
9149
|
+
f"qa_policy.manual_case_missing_acceptance_refs: manual test case {case_label} must list acceptance_criteria_refs."
|
|
9150
|
+
)
|
|
9151
|
+
if not isinstance(raw_case.get("steps"), list) or not raw_case["steps"]:
|
|
9152
|
+
issues.append(
|
|
9153
|
+
f"qa_policy.manual_case_missing_steps: manual test case {case_label} must define executable steps."
|
|
9154
|
+
)
|
|
9155
|
+
if raw_case.get("script_path") or str(raw_case.get("script_content") or "").strip():
|
|
9156
|
+
issues.append(
|
|
9157
|
+
f"qa_policy.manual_case_has_script: manual test case {case_label} must not define a script."
|
|
9158
|
+
)
|
|
9159
|
+
continue
|
|
9160
|
+
|
|
9161
|
+
if case_type not in _QA_TEST_LEVELS:
|
|
9162
|
+
issues.append(
|
|
9163
|
+
f"qa_policy.test_level_unclassified: test case {case_label} has unsupported test_type {case_type!r}."
|
|
9164
|
+
)
|
|
9165
|
+
continue
|
|
9166
|
+
if not policy["levels"][case_type]["enabled"]:
|
|
9167
|
+
issues.append(
|
|
9168
|
+
f"qa_policy.level_disabled: test case {case_label} uses disabled test_type {case_type!r}."
|
|
9169
|
+
)
|
|
9170
|
+
continue
|
|
9171
|
+
if intent_id:
|
|
9172
|
+
covered_automated_intents[case_type].add(intent_id)
|
|
9173
|
+
|
|
9174
|
+
runner_family = raw_case.get("runner_family")
|
|
9175
|
+
runner = runner_family.strip().lower() if isinstance(runner_family, str) else ""
|
|
9176
|
+
if not runner:
|
|
9177
|
+
issues.append(
|
|
9178
|
+
f"qa_policy.runner_family_missing: automated test case {case_label} must define runner_family."
|
|
9179
|
+
)
|
|
9180
|
+
elif (case_type == "e2e" and runner != "playwright") or (
|
|
9181
|
+
case_type in {"unit", "integration"} and runner == "playwright"
|
|
9182
|
+
):
|
|
9183
|
+
issues.append(
|
|
9184
|
+
f"qa_policy.runner_level_mismatch: test case {case_label} runner_family {runner!r} conflicts with test_type {case_type!r}."
|
|
9185
|
+
)
|
|
9186
|
+
|
|
9187
|
+
script_content = raw_case.get("script_content")
|
|
9188
|
+
if not isinstance(script_content, str) or not script_content.strip():
|
|
9189
|
+
issues.append(
|
|
9190
|
+
f"qa_policy.script_content_missing: automated test case {case_label} must define non-empty script_content."
|
|
9191
|
+
)
|
|
9192
|
+
script_path = raw_case.get("script_path")
|
|
9193
|
+
if not isinstance(script_path, str) or not script_path.strip():
|
|
9194
|
+
issues.append(
|
|
9195
|
+
f"qa_policy.script_path_missing: automated test case {case_label} must define script_path."
|
|
9196
|
+
)
|
|
9197
|
+
else:
|
|
9198
|
+
candidate_path = Path(script_path)
|
|
9199
|
+
if candidate_path.is_absolute() or ".." in candidate_path.parts:
|
|
9200
|
+
issues.append(
|
|
9201
|
+
f"qa_policy.script_path_invalid: automated test case {case_label} script_path must stay inside the workspace."
|
|
9202
|
+
)
|
|
9203
|
+
elif not (workspace_path / candidate_path).is_file():
|
|
9204
|
+
issues.append(
|
|
9205
|
+
f"qa_policy.script_path_missing: automated test case {case_label} script_path '{script_path}' does not exist on disk."
|
|
9206
|
+
)
|
|
9207
|
+
|
|
9208
|
+
for level, intent_ids in automated_intent_ids.items():
|
|
9209
|
+
missing = sorted(intent_ids - covered_automated_intents[level])
|
|
9210
|
+
if missing:
|
|
9211
|
+
issues.append(
|
|
9212
|
+
f"qa_policy.test_case_missing: test-cases.json lacks {level} test cases for intents {missing}."
|
|
9213
|
+
)
|
|
9214
|
+
missing_manual = sorted(set(manual_intent_reasons) - covered_manual_intents)
|
|
9215
|
+
if missing_manual:
|
|
9216
|
+
issues.append(
|
|
9217
|
+
f"qa_policy.manual_case_missing: test-cases.json lacks manual test cases for intents {missing_manual}."
|
|
9218
|
+
)
|
|
9219
|
+
return issues
|
|
9220
|
+
|
|
9221
|
+
|
|
8958
9222
|
def _configured_daemon_api_token() -> str:
|
|
8959
9223
|
token = str(settings.DAEMON_API_TOKEN or "").strip()
|
|
8960
9224
|
if token.startswith("pat_"):
|
|
@@ -8998,6 +9262,10 @@ class RuntimeDaemon:
|
|
|
8998
9262
|
"""
|
|
8999
9263
|
|
|
9000
9264
|
AGENT_REFRESH_INTERVAL_SECONDS = 60
|
|
9265
|
+
_TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS = 120
|
|
9266
|
+
_TASK_ATTACHMENT_MAX_BYTES = 50 * 1024 * 1024
|
|
9267
|
+
_TASK_ATTACHMENT_MAX_COUNT = 20
|
|
9268
|
+
_TASK_ATTACHMENT_MAX_TOTAL_BYTES = 100 * 1024 * 1024
|
|
9001
9269
|
|
|
9002
9270
|
def __init__(self):
|
|
9003
9271
|
# Compute stable hardware fingerprint from machine-id / MAC address.
|
|
@@ -9189,6 +9457,296 @@ class RuntimeDaemon:
|
|
|
9189
9457
|
|
|
9190
9458
|
target_dir.mkdir(parents=True, exist_ok=True)
|
|
9191
9459
|
|
|
9460
|
+
@staticmethod
|
|
9461
|
+
def _task_attachments_to_stage(task: TaskInfo) -> list[dict]:
|
|
9462
|
+
input_data = task.input_data if isinstance(task.input_data, dict) else {}
|
|
9463
|
+
attachment_groups = [input_data.get("attachments")]
|
|
9464
|
+
context = input_data.get("context")
|
|
9465
|
+
if isinstance(context, dict):
|
|
9466
|
+
attachment_groups.append(context.get("attachments"))
|
|
9467
|
+
fix_context = input_data.get("fix_context")
|
|
9468
|
+
if isinstance(fix_context, dict):
|
|
9469
|
+
attachment_groups.append(fix_context.get("gate_attachments"))
|
|
9470
|
+
|
|
9471
|
+
attachments: list[dict] = []
|
|
9472
|
+
attachments_by_artifact_id: dict[str, dict] = {}
|
|
9473
|
+
attachments_by_url: dict[str, dict] = {}
|
|
9474
|
+
for group in attachment_groups:
|
|
9475
|
+
if not isinstance(group, list):
|
|
9476
|
+
continue
|
|
9477
|
+
for attachment in group:
|
|
9478
|
+
if not isinstance(attachment, dict):
|
|
9479
|
+
continue
|
|
9480
|
+
artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "").strip()
|
|
9481
|
+
raw_download_url = attachment.get("download_url")
|
|
9482
|
+
download_url = raw_download_url.strip() if isinstance(raw_download_url, str) else ""
|
|
9483
|
+
if not artifact_id and not download_url:
|
|
9484
|
+
continue
|
|
9485
|
+
existing = (
|
|
9486
|
+
attachments_by_artifact_id.get(artifact_id)
|
|
9487
|
+
if artifact_id else None
|
|
9488
|
+
) or (attachments_by_url.get(download_url) if download_url else None)
|
|
9489
|
+
if existing is None:
|
|
9490
|
+
existing = dict(attachment)
|
|
9491
|
+
attachments.append(existing)
|
|
9492
|
+
else:
|
|
9493
|
+
for field, value in attachment.items():
|
|
9494
|
+
if value and not existing.get(field):
|
|
9495
|
+
existing[field] = value
|
|
9496
|
+
existing_artifact_id = str(
|
|
9497
|
+
existing.get("artifact_id") or existing.get("id") or ""
|
|
9498
|
+
).strip()
|
|
9499
|
+
if existing_artifact_id:
|
|
9500
|
+
attachments_by_artifact_id[existing_artifact_id] = existing
|
|
9501
|
+
if download_url:
|
|
9502
|
+
attachments_by_url[download_url] = existing
|
|
9503
|
+
|
|
9504
|
+
def _artifact_id_is_valid(attachment: dict) -> bool:
|
|
9505
|
+
try:
|
|
9506
|
+
UUID(str(attachment.get("artifact_id") or attachment.get("id") or ""))
|
|
9507
|
+
return True
|
|
9508
|
+
except (TypeError, ValueError, AttributeError):
|
|
9509
|
+
return False
|
|
9510
|
+
|
|
9511
|
+
def _legacy_key(attachment: dict) -> tuple[str, str, int]:
|
|
9512
|
+
try:
|
|
9513
|
+
size_bytes = int(attachment.get("size_bytes") or 0)
|
|
9514
|
+
except (TypeError, ValueError):
|
|
9515
|
+
size_bytes = 0
|
|
9516
|
+
return (
|
|
9517
|
+
str(attachment.get("file_path") or attachment.get("file_name") or ""),
|
|
9518
|
+
str(attachment.get("mime_type") or ""),
|
|
9519
|
+
size_bytes,
|
|
9520
|
+
)
|
|
9521
|
+
|
|
9522
|
+
identified_by_key: dict[tuple[str, str, int], list[dict]] = {}
|
|
9523
|
+
for attachment in attachments:
|
|
9524
|
+
if _artifact_id_is_valid(attachment):
|
|
9525
|
+
identified_by_key.setdefault(_legacy_key(attachment), []).append(attachment)
|
|
9526
|
+
|
|
9527
|
+
deduplicated: list[dict] = []
|
|
9528
|
+
for attachment in attachments:
|
|
9529
|
+
if _artifact_id_is_valid(attachment):
|
|
9530
|
+
deduplicated.append(attachment)
|
|
9531
|
+
continue
|
|
9532
|
+
candidates = identified_by_key.get(_legacy_key(attachment), [])
|
|
9533
|
+
if len(candidates) != 1:
|
|
9534
|
+
deduplicated.append(attachment)
|
|
9535
|
+
continue
|
|
9536
|
+
identified = candidates[0]
|
|
9537
|
+
for field, value in attachment.items():
|
|
9538
|
+
if value and not identified.get(field):
|
|
9539
|
+
identified[field] = value
|
|
9540
|
+
return deduplicated
|
|
9541
|
+
|
|
9542
|
+
@staticmethod
|
|
9543
|
+
def _task_attachment_filename(attachment: dict, position: int) -> str:
|
|
9544
|
+
raw_name = str(
|
|
9545
|
+
attachment.get("file_path") or attachment.get("file_name") or "attachment"
|
|
9546
|
+
)
|
|
9547
|
+
file_name = Path(raw_name.replace("\\", "/")).name
|
|
9548
|
+
file_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file_name).strip("._")
|
|
9549
|
+
return f"{position:02d}-{file_name[:160] or 'attachment'}"
|
|
9550
|
+
|
|
9551
|
+
@staticmethod
|
|
9552
|
+
def _rewrite_staged_attachment_references(
|
|
9553
|
+
prompt: str,
|
|
9554
|
+
staged: list[tuple[str, str, str]],
|
|
9555
|
+
) -> str:
|
|
9556
|
+
if not staged:
|
|
9557
|
+
return prompt
|
|
9558
|
+
|
|
9559
|
+
local_files = []
|
|
9560
|
+
for file_name, download_url, local_path in staged:
|
|
9561
|
+
if download_url:
|
|
9562
|
+
command_pattern = r"`curl -L '" + re.escape(download_url) + r"' -o /tmp/[^`]+`"
|
|
9563
|
+
prompt = re.sub(command_pattern, lambda _match, path=local_path: f"`{path}`", prompt)
|
|
9564
|
+
prompt = prompt.replace(f"Download: {download_url}", f"Staged local file: `{local_path}`")
|
|
9565
|
+
prompt = prompt.replace(download_url, local_path)
|
|
9566
|
+
local_files.append(f"- `{local_path}` ({file_name})")
|
|
9567
|
+
|
|
9568
|
+
preamble = "\n".join([
|
|
9569
|
+
"## Runtime-Prepared Attachments",
|
|
9570
|
+
"The runtime downloaded these files before this task began. Use the local copies directly; do not download attachment URLs yourself.",
|
|
9571
|
+
*local_files,
|
|
9572
|
+
])
|
|
9573
|
+
return f"{preamble}\n\n{prompt}" if prompt else preamble
|
|
9574
|
+
|
|
9575
|
+
async def _download_task_attachment(
|
|
9576
|
+
self,
|
|
9577
|
+
client: httpx.AsyncClient,
|
|
9578
|
+
url: str,
|
|
9579
|
+
destination: Path,
|
|
9580
|
+
*,
|
|
9581
|
+
attachment_name: str,
|
|
9582
|
+
expected_size: int | None,
|
|
9583
|
+
expected_checksum: str | None,
|
|
9584
|
+
max_bytes: int,
|
|
9585
|
+
timeout_seconds: float,
|
|
9586
|
+
) -> None:
|
|
9587
|
+
parsed_url = urlparse(url)
|
|
9588
|
+
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
9589
|
+
raise _TaskAttachmentDownloadError(
|
|
9590
|
+
f"Attachment '{attachment_name}' has an unsupported download URL"
|
|
9591
|
+
)
|
|
9592
|
+
if expected_size is not None and expected_size < 0:
|
|
9593
|
+
raise _TaskAttachmentDownloadError(
|
|
9594
|
+
f"Attachment '{attachment_name}' has an invalid size"
|
|
9595
|
+
)
|
|
9596
|
+
if expected_size is not None and expected_size > max_bytes:
|
|
9597
|
+
raise _TaskAttachmentDownloadError(
|
|
9598
|
+
f"Attachment '{attachment_name}' exceeds the task download budget"
|
|
9599
|
+
)
|
|
9600
|
+
if expected_checksum and not re.fullmatch(r"[0-9a-fA-F]{64}", expected_checksum):
|
|
9601
|
+
raise _TaskAttachmentDownloadError(
|
|
9602
|
+
f"Attachment '{attachment_name}' has an invalid integrity checksum"
|
|
9603
|
+
)
|
|
9604
|
+
|
|
9605
|
+
temporary_path = destination.with_name(f"{destination.name}.part")
|
|
9606
|
+
|
|
9607
|
+
async def download() -> None:
|
|
9608
|
+
try:
|
|
9609
|
+
async with client.stream(
|
|
9610
|
+
"GET",
|
|
9611
|
+
url,
|
|
9612
|
+
timeout=httpx.Timeout(connect=10.0, read=30.0, write=10.0, pool=10.0),
|
|
9613
|
+
) as response:
|
|
9614
|
+
if not 200 <= response.status_code < 300:
|
|
9615
|
+
raise _TaskAttachmentDownloadError(
|
|
9616
|
+
f"Attachment '{attachment_name}' download failed with HTTP {response.status_code}"
|
|
9617
|
+
)
|
|
9618
|
+
content_length = response.headers.get("content-length")
|
|
9619
|
+
if content_length:
|
|
9620
|
+
try:
|
|
9621
|
+
if int(content_length) > max_bytes:
|
|
9622
|
+
raise _TaskAttachmentDownloadError(
|
|
9623
|
+
f"Attachment '{attachment_name}' exceeds the task download budget"
|
|
9624
|
+
)
|
|
9625
|
+
except ValueError:
|
|
9626
|
+
pass
|
|
9627
|
+
|
|
9628
|
+
total_bytes = 0
|
|
9629
|
+
digest = hashlib.sha256()
|
|
9630
|
+
with temporary_path.open("wb") as output:
|
|
9631
|
+
try:
|
|
9632
|
+
os.chmod(temporary_path, 0o600)
|
|
9633
|
+
except OSError:
|
|
9634
|
+
pass
|
|
9635
|
+
async for chunk in response.aiter_bytes():
|
|
9636
|
+
total_bytes += len(chunk)
|
|
9637
|
+
if total_bytes > max_bytes:
|
|
9638
|
+
raise _TaskAttachmentDownloadError(
|
|
9639
|
+
f"Attachment '{attachment_name}' exceeds the task download budget"
|
|
9640
|
+
)
|
|
9641
|
+
output.write(chunk)
|
|
9642
|
+
digest.update(chunk)
|
|
9643
|
+
if expected_checksum:
|
|
9644
|
+
if not secrets.compare_digest(digest.hexdigest(), expected_checksum.lower()):
|
|
9645
|
+
raise _TaskAttachmentDownloadError(
|
|
9646
|
+
f"Attachment '{attachment_name}' failed its integrity check"
|
|
9647
|
+
)
|
|
9648
|
+
temporary_path.replace(destination)
|
|
9649
|
+
except _TaskAttachmentDownloadError:
|
|
9650
|
+
raise
|
|
9651
|
+
except httpx.HTTPError as exc:
|
|
9652
|
+
raise _TaskAttachmentDownloadError(
|
|
9653
|
+
f"Attachment '{attachment_name}' download failed ({type(exc).__name__})"
|
|
9654
|
+
) from exc
|
|
9655
|
+
except OSError as exc:
|
|
9656
|
+
raise _TaskAttachmentDownloadError(
|
|
9657
|
+
f"Attachment '{attachment_name}' could not be saved ({exc})"
|
|
9658
|
+
) from exc
|
|
9659
|
+
|
|
9660
|
+
try:
|
|
9661
|
+
await asyncio.wait_for(
|
|
9662
|
+
download(),
|
|
9663
|
+
timeout=timeout_seconds,
|
|
9664
|
+
)
|
|
9665
|
+
except asyncio.TimeoutError as exc:
|
|
9666
|
+
raise _TaskAttachmentDownloadError(
|
|
9667
|
+
f"Attachment '{attachment_name}' download timed out after "
|
|
9668
|
+
f"{timeout_seconds:.0f}s"
|
|
9669
|
+
) from exc
|
|
9670
|
+
finally:
|
|
9671
|
+
temporary_path.unlink(missing_ok=True)
|
|
9672
|
+
|
|
9673
|
+
async def _stage_task_attachments(
|
|
9674
|
+
self,
|
|
9675
|
+
task: TaskInfo,
|
|
9676
|
+
conn: ServerConnection,
|
|
9677
|
+
) -> Path | None:
|
|
9678
|
+
attachments = self._task_attachments_to_stage(task)
|
|
9679
|
+
if not attachments:
|
|
9680
|
+
return None
|
|
9681
|
+
if len(attachments) > self._TASK_ATTACHMENT_MAX_COUNT:
|
|
9682
|
+
raise _TaskAttachmentDownloadError(
|
|
9683
|
+
f"Task has {len(attachments)} attachments; the runtime limit is "
|
|
9684
|
+
f"{self._TASK_ATTACHMENT_MAX_COUNT}"
|
|
9685
|
+
)
|
|
9686
|
+
|
|
9687
|
+
staging_dir = Path(tempfile.mkdtemp(prefix=f"forgexa-attachments-{task.task_id[:8]}-"))
|
|
9688
|
+
staged: list[tuple[str, str, str]] = []
|
|
9689
|
+
deadline = time.monotonic() + self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS
|
|
9690
|
+
remaining_bytes = self._TASK_ATTACHMENT_MAX_TOTAL_BYTES
|
|
9691
|
+
try:
|
|
9692
|
+
for position, attachment in enumerate(attachments, start=1):
|
|
9693
|
+
file_name = str(
|
|
9694
|
+
attachment.get("file_path") or attachment.get("file_name") or "attachment"
|
|
9695
|
+
)
|
|
9696
|
+
download_url = str(attachment.get("download_url") or "")
|
|
9697
|
+
destination = staging_dir / self._task_attachment_filename(attachment, position)
|
|
9698
|
+
try:
|
|
9699
|
+
expected_size = int(attachment.get("size_bytes") or 0)
|
|
9700
|
+
except (TypeError, ValueError):
|
|
9701
|
+
expected_size = 0
|
|
9702
|
+
if expected_size > remaining_bytes:
|
|
9703
|
+
raise _TaskAttachmentDownloadError(
|
|
9704
|
+
f"Attachment '{file_name}' exceeds the task download budget"
|
|
9705
|
+
)
|
|
9706
|
+
artifact_id = str(attachment.get("artifact_id") or attachment.get("id") or "")
|
|
9707
|
+
try:
|
|
9708
|
+
UUID(artifact_id)
|
|
9709
|
+
except (TypeError, ValueError, AttributeError):
|
|
9710
|
+
raise _TaskAttachmentDownloadError(
|
|
9711
|
+
f"Attachment '{file_name}' cannot be staged securely because it has no artifact ID"
|
|
9712
|
+
)
|
|
9713
|
+
remaining_seconds = deadline - time.monotonic()
|
|
9714
|
+
if remaining_seconds <= 0:
|
|
9715
|
+
raise _TaskAttachmentDownloadError(
|
|
9716
|
+
f"Task attachment staging timed out after "
|
|
9717
|
+
f"{self._TASK_ATTACHMENT_DOWNLOAD_TIMEOUT_SECONDS}s"
|
|
9718
|
+
)
|
|
9719
|
+
if not conn.runtime_id:
|
|
9720
|
+
raise _TaskAttachmentDownloadError(
|
|
9721
|
+
f"Attachment '{file_name}' cannot be staged because this runtime is not registered"
|
|
9722
|
+
)
|
|
9723
|
+
source_url = (
|
|
9724
|
+
f"{conn.server_url.rstrip('/')}/api/v1/runtimes/{conn.runtime_id}/"
|
|
9725
|
+
f"tasks/{task.task_id}/attachments/{artifact_id}"
|
|
9726
|
+
)
|
|
9727
|
+
await self._download_task_attachment(
|
|
9728
|
+
conn.client,
|
|
9729
|
+
source_url,
|
|
9730
|
+
destination,
|
|
9731
|
+
attachment_name=file_name,
|
|
9732
|
+
expected_size=expected_size or None,
|
|
9733
|
+
expected_checksum=str(attachment.get("checksum") or ""),
|
|
9734
|
+
max_bytes=min(self._TASK_ATTACHMENT_MAX_BYTES, remaining_bytes),
|
|
9735
|
+
timeout_seconds=remaining_seconds,
|
|
9736
|
+
)
|
|
9737
|
+
remaining_bytes -= destination.stat().st_size
|
|
9738
|
+
staged.append((file_name, download_url, str(destination)))
|
|
9739
|
+
except Exception:
|
|
9740
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
9741
|
+
raise
|
|
9742
|
+
|
|
9743
|
+
task.input_prompt = self._rewrite_staged_attachment_references(task.input_prompt, staged)
|
|
9744
|
+
input_data = dict(task.input_data or {})
|
|
9745
|
+
input_data["prompt"] = task.input_prompt
|
|
9746
|
+
task.input_data = input_data
|
|
9747
|
+
logger.info("Prepared %d attachment(s) for task %s", len(staged), task.task_id)
|
|
9748
|
+
return staging_dir
|
|
9749
|
+
|
|
9192
9750
|
@staticmethod
|
|
9193
9751
|
async def _mint_local_dev_token() -> str:
|
|
9194
9752
|
"""Mint a JWT for local development when no explicit token is configured.
|
|
@@ -10033,6 +10591,7 @@ class RuntimeDaemon:
|
|
|
10033
10591
|
await project_lock.acquire()
|
|
10034
10592
|
workspace_execution_lock: _WorkspaceFileLock | None = None
|
|
10035
10593
|
local_bind_file_lock = None
|
|
10594
|
+
attachment_staging_dir: Path | None = None
|
|
10036
10595
|
if is_local_bind and task.local_workspace_binding_id:
|
|
10037
10596
|
# OS-level file lock keyed by binding_id (docs/designs/forgexa-
|
|
10038
10597
|
# ide-design.md §6.3): the DB lease (orchestrator.py) prevents
|
|
@@ -10128,6 +10687,34 @@ class RuntimeDaemon:
|
|
|
10128
10687
|
logger.info("Workspace ready: %s", workspace_path)
|
|
10129
10688
|
self._ensure_task_output_dir(workspace_path, task)
|
|
10130
10689
|
|
|
10690
|
+
try:
|
|
10691
|
+
if self._task_attachments_to_stage(task):
|
|
10692
|
+
await reporter.report_progress(
|
|
10693
|
+
task.task_id,
|
|
10694
|
+
8,
|
|
10695
|
+
"preparing_attachments",
|
|
10696
|
+
output_lines=["[daemon] Preparing task attachments"],
|
|
10697
|
+
)
|
|
10698
|
+
attachment_staging_dir = await self._stage_task_attachments(task, conn)
|
|
10699
|
+
except _TaskAttachmentDownloadError as exc:
|
|
10700
|
+
logger.warning("Task %s could not stage attachments: %s", task.task_id, exc)
|
|
10701
|
+
await reporter.report_progress(
|
|
10702
|
+
task.task_id,
|
|
10703
|
+
8,
|
|
10704
|
+
"attachment_download_failed",
|
|
10705
|
+
output_lines=["[daemon] Required task attachment could not be downloaded"],
|
|
10706
|
+
)
|
|
10707
|
+
await report_task_complete(TaskResult(
|
|
10708
|
+
status="failed",
|
|
10709
|
+
exit_code=-1,
|
|
10710
|
+
stdout="",
|
|
10711
|
+
stderr="",
|
|
10712
|
+
error=str(exc),
|
|
10713
|
+
failure_code="attachment_download_failed",
|
|
10714
|
+
execution_attempt_id=task.execution_attempt_id,
|
|
10715
|
+
))
|
|
10716
|
+
return
|
|
10717
|
+
|
|
10131
10718
|
# 2.1 Workspace health check: detect broken checkout — skipped for
|
|
10132
10719
|
# local_bind (the user's own real directory, not a disposable
|
|
10133
10720
|
# checkout the daemon controls). See
|
|
@@ -10254,7 +10841,7 @@ class RuntimeDaemon:
|
|
|
10254
10841
|
|
|
10255
10842
|
tried_agents.add(agent.agent_id)
|
|
10256
10843
|
|
|
10257
|
-
# ── Agent fallback: try another CLI after a backend failure or a
|
|
10844
|
+
# ── Agent fallback: try another CLI after a backend failure or a work-free hang ──
|
|
10258
10845
|
# Guard: if the agent already produced file changes in the workspace, it DID
|
|
10259
10846
|
# meaningful work — don't trigger fallback even if it crashed after completing.
|
|
10260
10847
|
# Let the recovery logic (step 4.1) handle non-zero exit with committed work.
|
|
@@ -10268,12 +10855,12 @@ class RuntimeDaemon:
|
|
|
10268
10855
|
else ProcessManager.agent_failure_kind(result)
|
|
10269
10856
|
)
|
|
10270
10857
|
is_agent_fallback_eligible = agent_failure_kind is not None
|
|
10271
|
-
|
|
10858
|
+
is_idle_timeout_without_work = self.process_manager.is_idle_timeout_without_work(result)
|
|
10272
10859
|
fallback_reason = (
|
|
10273
10860
|
agent_failure_kind
|
|
10274
10861
|
if is_agent_fallback_eligible
|
|
10275
10862
|
else "agent_idle_timeout"
|
|
10276
|
-
if
|
|
10863
|
+
if is_idle_timeout_without_work
|
|
10277
10864
|
else ""
|
|
10278
10865
|
)
|
|
10279
10866
|
fallback_label = (
|
|
@@ -10872,6 +11459,8 @@ class RuntimeDaemon:
|
|
|
10872
11459
|
execution_attempt_id=task.execution_attempt_id,
|
|
10873
11460
|
))
|
|
10874
11461
|
finally:
|
|
11462
|
+
if attachment_staging_dir is not None:
|
|
11463
|
+
shutil.rmtree(attachment_staging_dir, ignore_errors=True)
|
|
10875
11464
|
if workspace_execution_lock is not None:
|
|
10876
11465
|
workspace_execution_lock.release()
|
|
10877
11466
|
project_lock.release()
|
|
@@ -11396,6 +11985,10 @@ class RuntimeDaemon:
|
|
|
11396
11985
|
|
|
11397
11986
|
if not _skip_test_artifacts:
|
|
11398
11987
|
_input = task.input_data or {}
|
|
11988
|
+
_qa_policy, _qa_policy_issues = _parse_frozen_qa_test_level_policy(
|
|
11989
|
+
_input.get("qa_test_level_policy_snapshot")
|
|
11990
|
+
)
|
|
11991
|
+
issues.extend(_qa_policy_issues)
|
|
11399
11992
|
doc_dir = (
|
|
11400
11993
|
_input.get("output_dir")
|
|
11401
11994
|
or _input.get("context", {}).get("output_dir")
|
|
@@ -11411,6 +12004,8 @@ class RuntimeDaemon:
|
|
|
11411
12004
|
# intents gets the same E2E chain validation as feature/improvement.
|
|
11412
12005
|
_e2e_intent_ids: set[str] = set()
|
|
11413
12006
|
_e2e_case_ids: list[str] = []
|
|
12007
|
+
_ti_data: dict[str, Any] | None = None
|
|
12008
|
+
_tc_data: dict[str, Any] | None = None
|
|
11414
12009
|
_analysis_dir = (
|
|
11415
12010
|
_input.get("analysis_output_dir")
|
|
11416
12011
|
or (_input.get("context") or {}).get("analysis_output_dir")
|
|
@@ -11424,6 +12019,8 @@ class RuntimeDaemon:
|
|
|
11424
12019
|
_ti_path.read_text(encoding="utf-8")
|
|
11425
12020
|
)
|
|
11426
12021
|
for _ti in _ti_data.get("intents", []):
|
|
12022
|
+
if not isinstance(_ti, dict):
|
|
12023
|
+
continue
|
|
11427
12024
|
if (_ti.get("automation_target") or "").lower() == "e2e":
|
|
11428
12025
|
_ti_id = (
|
|
11429
12026
|
_ti.get("id")
|
|
@@ -11445,8 +12042,8 @@ class RuntimeDaemon:
|
|
|
11445
12042
|
tc_path = base / "test-cases.json"
|
|
11446
12043
|
if tc_path.exists():
|
|
11447
12044
|
try:
|
|
11448
|
-
|
|
11449
|
-
cases =
|
|
12045
|
+
_tc_data = _json.loads(tc_path.read_text(encoding="utf-8"))
|
|
12046
|
+
cases = _tc_data.get("test_cases", [])
|
|
11450
12047
|
_e2e_case_ids = [
|
|
11451
12048
|
str(_tc.get("id"))
|
|
11452
12049
|
for _tc in cases
|
|
@@ -11526,6 +12123,14 @@ class RuntimeDaemon:
|
|
|
11526
12123
|
elif _requires_structured_artifacts or _requires_e2e_artifacts:
|
|
11527
12124
|
issues.append(f"test-cases.json not found in {doc_dir or 'workspace root'}")
|
|
11528
12125
|
|
|
12126
|
+
if _qa_policy is not None:
|
|
12127
|
+
issues.extend(_validate_enforced_qa_test_level_artifacts(
|
|
12128
|
+
_qa_policy,
|
|
12129
|
+
_ti_data,
|
|
12130
|
+
_tc_data,
|
|
12131
|
+
workspace_path,
|
|
12132
|
+
))
|
|
12133
|
+
|
|
11529
12134
|
# --- coverage-matrix.json validation ---
|
|
11530
12135
|
cm_path = base / "coverage-matrix.json"
|
|
11531
12136
|
if cm_path.exists():
|
|
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
|