feat: 轻量拓展分析 + WGS2GWAS 阶段支持 + 结果区域重构 + 物种角色解析增强 + 会话 Job 隔离

【轻量拓展分析】
- 新增 gfa_core/extension_stage_lightweight/ 核心模块,支持用户上传代码/数据/原始命令
- 前端卡片式交互流程:候选核对 → 验证 → 结果审查 → 授权发布 → 运行
- Celery 不受限任务 + 轮询机制 + sessionStorage 操作恢复
- 启动脚本新增 BBAV5_LIGHTWEIGHT_* 环境变量

【WGS2GWAS 支持】
- 新增 13 个 WGS 阶段定义(QC → MAF/Missing Filter)
- email_haplotype_region.py 新增 LTR-RT 分析功能

【结果区域重构】
- 单层折叠 → 三组双层折叠:RNA-seq / 基因家族 / WGS2GWAS
- 结果按执行时间排序,保留最新尝试

【物种角色解析增强】
- 新增 _resolve_subfamily_request_species_roles()
- 支持 reference 角色的识别
- 直接 stage 请求保留 intent 中的物种角色

【会话 Job 隔离】
- rebind_job() 跨 job 时创建新会话,返回新 conversation_id

【前端交互增强】
- RAG 文献问答模式
- NL 触发 config/samples 自动填写、亚家族确认表、基因家族成员确认表
- 执行状态从后端 resource_tasks 恢复

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
potaser
2026-08-05 09:40:45 +08:00
co-authored by Claude Opus 5
parent f745ee9d7e
commit e641c8b9ab
53 changed files with 15831 additions and 92 deletions
+32 -1
View File
@@ -7,6 +7,28 @@ if str(ROOT) not in sys.path:
from apps.cli.run_config_autofill import build_rows, merge_rows
from apps.cli.autofill_common import scan_files
from gfa_core.mas.agents.router_runtime import RouterRuntime
def test_direct_config_open_commands_map_to_config_dialog_action():
for query in (
"打开 config",
"打开 config.xlsx",
"重新打开配置表",
"帮我打开当前 job 的配置",
):
assert RouterRuntime._config_dialog_ui_action(query) == "open_config_dialog"
def test_config_discussion_does_not_map_to_config_dialog_action():
for query in (
"读取 config.xlsx 中有哪些物种",
"检查 config 是否正确",
"我已经打开 config,下一步做什么",
"config 为什么打不开",
"config 已确认,执行数据准备",
):
assert RouterRuntime._config_dialog_ui_action(query) is None
def test_at_spl_pep_upload_is_detected_as_seed_reference(tmp_path):
@@ -98,6 +120,16 @@ def test_backend_open_endpoints_prefer_draft_then_confirmed_file():
assert 'input_dir / "samples.yaml"' in main_py
def test_chat_config_dialog_action_is_transmitted_and_opens_current_job_config():
main_py = (ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
html = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
assert '"ui_action": outcome.get("ui_action")' in main_py
assert "data.intent.ui_action === 'open_config_dialog'" in html
assert "await openAutofillDialog('config'" in html
assert "await openExistingAutofillDraft('config')" in html
def test_samples_confirm_no_longer_blocks_on_pending_de_roles_and_closes_dialog():
html = (ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
confirm_start = html.index("const confirmAutofillDraft")
@@ -110,4 +142,3 @@ def test_samples_confirm_no_longer_blocks_on_pending_de_roles_and_closes_dialog(
assert "请在样本表中至少标记一个比较组" not in confirm_block
assert "autofillDialogVisible.value = false" in confirm_block
@@ -0,0 +1,930 @@
from __future__ import annotations
import json
from pathlib import Path
import threading
import asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
class _FakeService:
def __init__(self, record: dict | None = None) -> None:
self.record = record or {
"extension_id": "count_fasta",
"status": "validation_running",
"operations": {
"validate-1": {
"action": "validate",
"state": "running",
"response": {
"extension_id": "count_fasta",
"operation_token": "validate-1",
"action": "validate",
"state": "running",
},
}
},
"validation_runs": [],
"runtime_runs": [],
}
self.stops: list[tuple[str, str]] = []
def get(self, extension_id: str) -> dict:
assert extension_id == self.record["extension_id"]
return self.record
def stop(self, extension_id: str, *, operation_token: str) -> dict:
self.stops.append((extension_id, operation_token))
return {
"extension_id": extension_id,
"operation_token": operation_token,
"stopped": True,
}
def help(self, extension_id: str) -> dict:
return {"extension_id": extension_id, "function_summary": "count FASTA records"}
def _client(tmp_path: Path, service: _FakeService | None = None):
from gfa_core.extension_stage_lightweight.api import (
LightweightApiSettings,
create_lightweight_extension_router,
)
queued: list[dict] = []
app = FastAPI()
app.include_router(
create_lightweight_extension_router(
settings=LightweightApiSettings(
project_root=tmp_path,
store_root=tmp_path / "store",
input_root=tmp_path / "input",
workspace_root=tmp_path / "workspace",
),
service_factory=lambda _settings: service or _FakeService(),
enqueue=lambda payload: queued.append(payload) or "celery-test-id",
)
)
return TestClient(app), queued
def _receipt(tmp_path: Path, *, token: str = "validate-1") -> None:
path = tmp_path / "store" / "operations" / f"{token}.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps(
{
"operation_id": token,
"operation_token": token,
"extension_id": "count_fasta",
"action": "validate",
"state": "queued",
"status": "queued",
}
),
encoding="utf-8",
)
def test_settings_use_explicit_environment_or_current_project_root(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.api import LightweightApiSettings
settings = LightweightApiSettings.from_environment(
environ={
"BBAV5_PROJECT_ROOT": str(tmp_path),
"BBAV5_LIGHTWEIGHT_EXTENSION_STORE": "isolated-store",
"BBAV5_LIGHTWEIGHT_INPUT": "isolated-input",
"BBAV5_LIGHTWEIGHT_WORKSPACE": "isolated-workspace",
}
)
assert settings.project_root == tmp_path
assert settings.store_root == tmp_path / "isolated-store"
assert settings.input_root == tmp_path / "isolated-input"
assert settings.workspace_root == tmp_path / "isolated-workspace"
assert "bbav5_0502_Yang_Tihuan.7880" not in str(settings.project_root)
project_root_only = LightweightApiSettings.from_environment(
environ={"PROJECT_ROOT": str(tmp_path)}
)
assert project_root_only.project_root == tmp_path
def test_worker_maps_logical_job_paths_to_configured_isolated_roots(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.api import (
LightweightApiSettings,
_runtime_input_path,
)
settings = LightweightApiSettings(
tmp_path,
tmp_path / "store",
tmp_path / "isolated-input",
tmp_path / "isolated-workspace",
)
assert _runtime_input_path(settings, "job_1", "input/job_1/sample.fa") == (
settings.input_root / "job_1" / "sample.fa"
)
assert _runtime_input_path(settings, "job_1", "workspace/job_1/sample.fa") == (
settings.workspace_root / "job_1" / "sample.fa"
)
def test_upload_persists_files_and_returns_operation_before_celery_runs(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "count_fasta",
"original_command": "bash count.sh input.fa",
"operation_token": "upload-1",
},
files=[
("raw_code_files", ("count.sh", b"grep -c '^>' input.fa\n", "text/plain")),
("raw_data_files", ("input.fa", b">a\nAAAA\n", "text/plain")),
],
)
assert response.status_code == 202
assert response.json()["operation_id"] == "upload-1"
assert response.json()["state"] == "queued"
assert len(queued) == 1
assert queued[0]["action"] == "upload"
assert queued[0]["original_command"] == "bash count.sh input.fa"
assert (tmp_path / "store" / "staging" / "upload-1" / "raw_code" / "count.sh").is_file()
assert (tmp_path / "store" / "staging" / "upload-1" / "raw_test_data" / "input.fa").is_file()
duplicate = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "count_fasta",
"original_command": "bash count.sh input.fa",
"operation_token": "upload-1",
},
files=[
("raw_code_files", ("count.sh", b"ignored", "text/plain")),
("raw_data_files", ("input.fa", b"ignored", "text/plain")),
],
)
assert duplicate.status_code == 202
assert len(queued) == 1
def test_retry_semantic_endpoint_enqueues_only_semantic_generation(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/count_fasta/retry-semantic",
json={"operation_token": "semantic-2"},
)
assert response.status_code == 202
assert response.json()["operation_id"] == "semantic-2"
assert queued == [
{
"extension_id": "count_fasta",
"action": "retry_semantic",
"operation_token": "semantic-2",
}
]
def test_upload_accepts_single_code_entry_without_original_command(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/upload",
data={"extension_id": "code_only", "operation_token": "upload-code-only"},
files=[
("raw_code_files", ("run.sh", b"printf 'ok\\n' > result.txt\n", "text/plain")),
("raw_data_files", ("input.fa", b">a\nAAAA\n", "text/plain")),
],
)
assert response.status_code == 202
assert queued[0]["original_command"] == "bash run.sh"
def test_upload_accepts_original_command_without_code_file(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "command_only",
"original_command": "wc -l input.txt",
"operation_token": "upload-command-only",
},
files=[("raw_data_files", ("input.txt", b"one\ntwo\n", "text/plain"))],
)
assert response.status_code == 202
assert queued[0]["original_command"] == "bash run.sh"
run_sh = tmp_path / "store" / "staging" / "upload-command-only" / "raw_code" / "run.sh"
assert run_sh.is_file()
assert "wc -l input.txt" in run_sh.read_text(encoding="utf-8")
def test_upload_accepts_server_data_file_as_symlink(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
source = tmp_path / "large" / "input.txt"
source.parent.mkdir()
source.write_text("one\ntwo\n", encoding="utf-8")
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "server_path",
"original_command": "wc -l input.txt",
"operation_token": "upload-server-path",
"raw_data_paths": str(source),
},
)
assert response.status_code == 202
assert len(queued) == 1
staged = tmp_path / "store" / "staging" / "upload-server-path" / "raw_test_data" / "input.txt"
assert staged.is_symlink()
assert staged.resolve() == source.resolve()
def test_upload_expands_server_data_directory_preserving_relative_layout(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
source = tmp_path / "large_case"
(source / "nested").mkdir(parents=True)
(source / "input.fa").write_text(">a\nAAAA\n", encoding="utf-8")
(source / "nested" / "model.hmm").write_text("HMMER3/f\n", encoding="utf-8")
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "server_directory",
"original_command": "printf ok",
"operation_token": "upload-server-directory",
"raw_data_paths": str(source),
},
)
assert response.status_code == 202
assert len(queued) == 1
staged = tmp_path / "store" / "staging" / "upload-server-directory" / "raw_test_data"
assert (staged / "input.fa").is_symlink()
assert (staged / "nested" / "model.hmm").is_symlink()
def test_upload_rejects_missing_server_data_path(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
missing = tmp_path / "missing.fa"
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "missing_server_path",
"original_command": "wc -l missing.fa",
"operation_token": "upload-missing-server-path",
"raw_data_paths": str(missing),
},
)
assert response.status_code == 400
assert f"测试数据路径不存在:{missing}" in response.json()["detail"]["message"]
assert queued == []
def test_upload_rejects_uploaded_and_server_path_name_collision(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
source = tmp_path / "large" / "input.txt"
source.parent.mkdir()
source.write_text("server\n", encoding="utf-8")
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "data_collision",
"original_command": "wc -l input.txt",
"operation_token": "upload-data-collision",
"raw_data_paths": str(source),
},
files=[("raw_data_files", ("input.txt", b"upload\n", "text/plain"))],
)
assert response.status_code == 400
assert "测试数据文件名冲突:input.txt" in response.json()["detail"]["message"]
assert queued == []
def test_server_path_submission_runs_real_raw_baseline(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
client, queued = _client(tmp_path)
source = tmp_path / "external_data" / "input.fa"
source.parent.mkdir()
source.write_text(">a\nAAAA\n>b\nBBBB\n", encoding="utf-8")
response = client.post(
"/api/lightweight-extension-stages/upload",
data={
"extension_id": "real_server_path",
"operation_token": "upload-real-server-path",
"raw_data_paths": str(source),
},
files=[
("raw_code_files", ("count.sh", b"grep -c '^>' input.fa\n", "text/plain")),
],
)
assert response.status_code == 202
assert queued[0]["original_command"] == "bash count.sh"
staged = tmp_path / "store" / "staging" / "upload-real-server-path"
evidence = run_raw_baseline(
command=queued[0]["original_command"],
raw_code=staged / "raw_code",
raw_data=staged / "raw_test_data",
run_dir=tmp_path / "real_baseline",
)
assert evidence["success"] is True
assert evidence["outputs"]["stdout"]["preview"] == "2"
assert (staged / "raw_test_data" / "input.fa").is_symlink()
assert source.read_text(encoding="utf-8").startswith(">a")
def test_upload_rejects_ambiguous_code_without_original_command(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/upload",
data={"extension_id": "ambiguous", "operation_token": "upload-ambiguous"},
files=[
("raw_code_files", ("run_a.sh", b"echo a\n", "text/plain")),
("raw_code_files", ("run_b.sh", b"echo b\n", "text/plain")),
("raw_data_files", ("input.txt", b"data\n", "text/plain")),
],
)
assert response.status_code == 400
assert "multiple shell entry candidates" in response.json()["detail"]["message"]
assert "provide original_command" in response.json()["detail"]["message"]
assert queued == []
def test_upload_rejects_when_command_and_code_are_both_missing(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/upload",
data={"extension_id": "missing_entry", "operation_token": "upload-missing"},
files=[("raw_data_files", ("input.txt", b"data\n", "text/plain"))],
)
assert response.status_code == 400
assert "no command or code file was provided" in response.json()["detail"]["message"]
assert queued == []
def test_operation_poll_and_stop_are_isolated_to_lightweight_service(tmp_path: Path) -> None:
service = _FakeService()
_receipt(tmp_path)
client, _queued = _client(tmp_path, service)
poll = client.get("/api/lightweight-extension-stages/operations/validate-1")
assert poll.status_code == 200
assert poll.json()["state"] == "running"
assert poll.json()["status"] == "validation_running"
stopped = client.post(
"/api/lightweight-extension-stages/count_fasta/operations/validate-1/stop"
)
assert stopped.status_code == 200
assert stopped.json()["stopped"] is True
assert service.stops == [("count_fasta", "validate-1")]
def test_operation_failure_reports_phase_message_and_logs(tmp_path: Path) -> None:
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "validation_failed",
"error": {"phase": "validation", "message": "input binding failed"},
"operations": {
"validate-1": {
"action": "validate",
"state": "completed",
"response": {"operation_token": "validate-1", "action": "validate"},
}
},
"validation_runs": [
{"run_id": "validation_1", "logs": {"stdout": "logs/stdout.log", "stderr": "logs/stderr.log"}}
],
"runtime_runs": [],
}
)
_receipt(tmp_path)
client, _queued = _client(tmp_path, service)
response = client.get("/api/lightweight-extension-stages/operations/validate-1")
assert response.status_code == 200
payload = response.json()
assert payload["error"] == {"phase": "validation", "message": "input binding failed"}
assert payload["logs"] == ["logs/stderr.log", "logs/stdout.log"]
def test_result_file_must_be_a_listed_safe_output(tmp_path: Path) -> None:
output = tmp_path / "store" / "extensions" / "count_fasta" / "runs" / "validation_1" / "outputs" / "result.txt"
output.parent.mkdir(parents=True)
output.write_text("answer\n", encoding="utf-8")
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "published",
"operations": {},
"validation_runs": [
{
"run_id": "validation_1",
"projection": {"files": [{"path": "result.txt", "absolute_path": str(output)}]},
}
],
"runtime_runs": [],
}
)
client, _queued = _client(tmp_path, service)
allowed = client.get(
"/api/lightweight-extension-stages/count_fasta/files",
params={"run_id": "validation_1", "path": "result.txt"},
)
rejected = client.get(
"/api/lightweight-extension-stages/count_fasta/files",
params={"run_id": "validation_1", "path": "../result.txt"},
)
assert allowed.status_code == 200
assert allowed.text == "answer\n"
assert rejected.status_code == 400
def test_list_route_reads_only_lightweight_store_records(tmp_path: Path) -> None:
record = tmp_path / "store" / "extensions" / "count_fasta" / "extension.json"
record.parent.mkdir(parents=True)
record.write_text(
json.dumps({"extension_id": "count_fasta", "status": "published"}),
encoding="utf-8",
)
client, _queued = _client(tmp_path)
response = client.get("/api/lightweight-extension-stages/")
assert response.status_code == 200
assert response.json()["extensions"] == [
{"extension_id": "count_fasta", "status": "published"}
]
def test_invalid_submission_uses_structured_phase_error(tmp_path: Path) -> None:
client, _queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/count_fasta/validate",
json={"operation_token": "../unsafe"},
)
assert response.status_code == 400
assert response.json()["detail"] == {
"phase": "validate",
"message": "operation_token must be one safe path component",
"logs": [],
}
def test_same_token_concurrent_reservation_has_one_winner(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.api import (
LightweightApiSettings,
_reserve_operation,
)
settings = LightweightApiSettings(tmp_path, tmp_path / "store", tmp_path / "input", tmp_path / "workspace")
start = threading.Barrier(8)
created: list[bool] = []
def reserve() -> None:
start.wait()
_receipt, won = _reserve_operation(
settings, extension_id="count_fasta", action="validate", token="same-token"
)
created.append(won)
workers = [threading.Thread(target=reserve) for _ in range(8)]
for worker in workers:
worker.start()
for worker in workers:
worker.join(timeout=3)
assert sum(created) == 1
def test_bad_upload_leaves_no_receipt_and_same_token_can_retry(tmp_path: Path) -> None:
client, queued = _client(tmp_path)
fields = {
"extension_id": "count_fasta",
"original_command": "bash count.sh input.fa",
"operation_token": "upload-retry",
}
bad = client.post(
"/api/lightweight-extension-stages/upload",
data=fields,
files=[
("raw_code_files", ("..\\count.sh", b"bad", "text/plain")),
("raw_data_files", ("input.fa", b">a\n", "text/plain")),
],
)
assert bad.status_code == 400
assert not (tmp_path / "store" / "operations" / "upload-retry.json").exists()
good = client.post(
"/api/lightweight-extension-stages/upload",
data=fields,
files=[
("raw_code_files", ("count.sh", b"grep -c '^>' input.fa\n", "text/plain")),
("raw_data_files", ("input.fa", b">a\n", "text/plain")),
],
)
assert good.status_code == 202
assert len(queued) == 1
def test_generic_intent_returns_standard_usage_without_extension_lookup(tmp_path: Path) -> None:
client, _queued = _client(tmp_path)
response = client.post(
"/api/lightweight-extension-stages/intent", json={"message": "如何使用拓展分析阶段?"}
)
assert response.status_code == 200
assert "上传" in response.json()["usage"]
assert "自然语言" in response.json()["usage"]
def test_poll_keeps_operation_snapshot_and_reports_later_extension_status(tmp_path: Path) -> None:
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "published",
"operations": {
"validate-1": {
"action": "validate",
"state": "completed",
"run_id": "validation_1",
"response": {"status": "result_review_pending", "run_id": "validation_1"},
}
},
"validation_runs": [{"run_id": "validation_1", "success": True, "contract_passed": True}],
"runtime_runs": [],
}
)
_receipt(tmp_path)
client, _queued = _client(tmp_path, service)
response = client.get("/api/lightweight-extension-stages/operations/validate-1")
assert response.status_code == 200
assert response.json()["status"] == "result_review_pending"
assert response.json()["extension_status"] == "published"
def test_poll_reports_failed_runtime_run_even_when_extension_is_published(tmp_path: Path) -> None:
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "published",
"operations": {
"validate-1": {"action": "validate", "state": "completed", "response": {}},
"run-1": {
"action": "run",
"state": "completed",
"run_id": "runtime_1",
"response": {"status": "published", "run_id": "runtime_1"},
},
},
"validation_runs": [],
"runtime_runs": [
{
"run_id": "runtime_1",
"success": False,
"contract_passed": False,
"binding": {"status": "blocked", "error": {"message": "required input missing"}},
"logs": {"stderr": "logs/runtime.stderr.log"},
}
],
}
)
_receipt(tmp_path, token="run-1")
receipt = tmp_path / "store" / "operations" / "run-1.json"
receipt.write_text(
json.dumps({"operation_id": "run-1", "operation_token": "run-1", "extension_id": "count_fasta", "action": "run", "state": "completed", "status": "published"}),
encoding="utf-8",
)
client, _queued = _client(tmp_path, service)
response = client.get("/api/lightweight-extension-stages/operations/run-1")
assert response.status_code == 200
assert response.json()["outcome"] == "failed"
assert response.json()["error"] == {
"phase": "runtime",
"message": "required input missing",
"logs": ["logs/runtime.stderr.log"],
}
def test_file_download_rejects_recorded_path_outside_expected_output_root(tmp_path: Path) -> None:
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "published",
"operations": {},
"validation_runs": [
{
"run_id": "validation_1",
"projection": {"files": [{"path": "result.txt", "absolute_path": "/etc/hosts"}]},
}
],
"runtime_runs": [],
}
)
client, _queued = _client(tmp_path, service)
response = client.get(
"/api/lightweight-extension-stages/count_fasta/files",
params={"run_id": "validation_1", "path": "result.txt"},
)
assert response.status_code == 400
def test_file_download_rejects_symlink_escape_from_expected_output_root(tmp_path: Path) -> None:
output = tmp_path / "store" / "extensions" / "count_fasta" / "runs" / "validation_1" / "outputs" / "result.txt"
output.parent.mkdir(parents=True)
output.symlink_to("/etc/hosts")
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "published",
"operations": {},
"validation_runs": [
{
"run_id": "validation_1",
"projection": {"files": [{"path": "result.txt", "absolute_path": str(output)}]},
}
],
"runtime_runs": [],
}
)
client, _queued = _client(tmp_path, service)
response = client.get(
"/api/lightweight-extension-stages/count_fasta/files",
params={"run_id": "validation_1", "path": "result.txt"},
)
assert response.status_code == 400
def test_old_upload_poll_does_not_project_a_later_baseline_run(tmp_path: Path) -> None:
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "confirmation_pending",
"operations": {
"upload-old": {
"action": "upload",
"state": "completed",
"run_id": "baseline_old",
"response": {"status": "baseline_failed", "run_id": "baseline_old"},
},
"rebuild-new": {
"action": "rebuild",
"state": "completed",
"run_id": "baseline_new",
"response": {"status": "confirmation_pending", "run_id": "baseline_new"},
},
},
"baseline": {
"run_dir": str(tmp_path / "store" / "extensions" / "count_fasta" / "baseline" / "baseline_new"),
"success": True,
"logs": {"stdout": "logs/new-baseline.stdout.log"},
},
"validation_runs": [],
"runtime_runs": [],
}
)
path = tmp_path / "store" / "operations" / "upload-old.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"operation_id": "upload-old", "operation_token": "upload-old", "extension_id": "count_fasta", "action": "upload", "state": "completed", "status": "baseline_failed"}),
encoding="utf-8",
)
client, _queued = _client(tmp_path, service)
response = client.get("/api/lightweight-extension-stages/operations/upload-old")
assert response.status_code == 200
assert response.json()["status"] == "baseline_failed"
assert "outcome" not in response.json()
assert "logs" not in response.json()
def test_upload_streaming_copy_uses_bounded_reads_and_preserves_content(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.api import UPLOAD_CHUNK_BYTES, _copy_upload_file
class _Upload:
filename = "large.bin"
def __init__(self, content: bytes) -> None:
self.content = content
self.offset = 0
self.read_sizes: list[int] = []
async def read(self, size: int = -1) -> bytes:
self.read_sizes.append(size)
chunk = self.content[self.offset : self.offset + size]
self.offset += len(chunk)
return chunk
content = b"A" * (UPLOAD_CHUNK_BYTES * 2 + 17)
upload = _Upload(content)
destination = tmp_path / "large.bin"
asyncio.run(_copy_upload_file(upload, destination))
assert destination.read_bytes() == content
assert upload.read_sizes == [UPLOAD_CHUNK_BYTES] * 4
def test_old_upload_poll_is_not_polluted_by_later_failed_rebuild(tmp_path: Path) -> None:
service = _FakeService(
{
"extension_id": "count_fasta",
"status": "baseline_failed",
"error": {"phase": "baseline", "message": "later rebuild failed"},
"operations": {
"upload-old": {
"action": "upload",
"state": "completed",
"run_id": "baseline_old",
"response": {"status": "baseline_failed", "run_id": "baseline_old"},
},
"rebuild-new": {
"action": "rebuild",
"state": "completed",
"run_id": "baseline_new",
"response": {"status": "baseline_failed", "run_id": "baseline_new"},
},
},
"baseline": {
"run_dir": str(tmp_path / "store" / "extensions" / "count_fasta" / "baseline" / "baseline_new"),
"success": False,
"logs": {"stderr": "logs/new-baseline.stderr.log"},
},
"validation_runs": [],
"runtime_runs": [],
}
)
path = tmp_path / "store" / "operations" / "upload-old.json"
path.parent.mkdir(parents=True)
path.write_text(
json.dumps({"operation_id": "upload-old", "operation_token": "upload-old", "extension_id": "count_fasta", "action": "upload", "state": "completed", "status": "baseline_failed"}),
encoding="utf-8",
)
client, _queued = _client(tmp_path, service)
response = client.get("/api/lightweight-extension-stages/operations/upload-old")
assert response.status_code == 200
assert "outcome" not in response.json()
assert "error" not in response.json()
assert "logs" not in response.json()
def test_current_pre_evidence_baseline_failure_is_saved_on_its_receipt(
tmp_path: Path, monkeypatch
) -> None:
import gfa_core.extension_stage_lightweight.api as api
settings = api.LightweightApiSettings(
project_root=tmp_path,
store_root=tmp_path / "store",
input_root=tmp_path / "input",
workspace_root=tmp_path / "workspace",
)
api._write_json(
api._receipt_path(settings, "upload-current"),
{"operation_id": "upload-current", "operation_token": "upload-current", "extension_id": "count_fasta", "action": "upload", "state": "queued", "status": "queued"},
)
record = {
"extension_id": "count_fasta",
"status": "baseline_failed",
"error": {"phase": "baseline", "message": "command failed before evidence"},
"operations": {
"upload-current": {
"action": "upload",
"state": "completed",
"run_id": "baseline_current",
"response": {"status": "baseline_failed", "run_id": "baseline_current"},
}
},
"validation_runs": [],
"runtime_runs": [],
}
class _CurrentFailureService:
def upload(self, **_kwargs):
return {"status": "baseline_failed"}
def get(self, extension_id: str):
assert extension_id == "count_fasta"
return record
monkeypatch.setattr(api.LightweightApiSettings, "from_environment", classmethod(lambda _cls: settings))
monkeypatch.setattr(api, "_service_for", lambda _settings: _CurrentFailureService())
receipt = api.execute_queued_operation(
{
"action": "upload",
"extension_id": "count_fasta",
"operation_token": "upload-current",
"original_command": "bash count.sh input.fa",
}
)
assert receipt["error"] == {
"phase": "baseline",
"message": "command failed before evidence",
"logs": [],
}
def test_current_pre_evidence_rebuild_failure_does_not_inherit_prior_baseline_logs(
tmp_path: Path, monkeypatch
) -> None:
import gfa_core.extension_stage_lightweight.api as api
settings = api.LightweightApiSettings(tmp_path, tmp_path / "store", tmp_path / "input", tmp_path / "workspace")
api._write_json(
api._receipt_path(settings, "rebuild-current"),
{"operation_id": "rebuild-current", "operation_token": "rebuild-current", "extension_id": "count_fasta", "action": "rebuild", "state": "queued", "status": "queued"},
)
record = {
"extension_id": "count_fasta",
"status": "baseline_failed",
"error": {"phase": "baseline", "message": "rebuild failed before evidence"},
"operations": {
"rebuild-current": {
"action": "rebuild",
"state": "completed",
"run_id": "baseline_current",
"response": {"status": "baseline_failed", "run_id": "baseline_current"},
}
},
"baseline": {
"run_dir": str(tmp_path / "store" / "extensions" / "count_fasta" / "baseline" / "baseline_prior"),
"logs": {"stderr": "logs/prior-baseline.stderr.log"},
},
"validation_runs": [],
"runtime_runs": [],
}
class _CurrentFailureService:
def rebuild(self, *_args, **_kwargs):
return {"status": "baseline_failed"}
def get(self, extension_id: str):
assert extension_id == "count_fasta"
return record
monkeypatch.setattr(api.LightweightApiSettings, "from_environment", classmethod(lambda _cls: settings))
monkeypatch.setattr(api, "_service_for", lambda _settings: _CurrentFailureService())
receipt = api.execute_queued_operation(
{"action": "rebuild", "extension_id": "count_fasta", "operation_token": "rebuild-current"}
)
assert receipt["error"]["logs"] == []
def test_celery_task_is_explicitly_unlimited_and_main_registers_only_new_router() -> None:
root = Path(__file__).resolve().parents[2]
tasks = (root / "tasks.py").read_text(encoding="utf-8")
celery = (root / "celery_app.py").read_text(encoding="utf-8")
main = (root / "apps/web/vue3/main.py").read_text(encoding="utf-8")
assert 'name="tasks.execute_lightweight_extension_operation"' in tasks
assert "time_limit=None" in tasks
assert "soft_time_limit=None" in tasks
assert '"tasks.execute_lightweight_extension_operation"' in celery
assert "create_lightweight_extension_router" in main
assert "/api/extension-stages" not in (root / "gfa_core/extension_stage_lightweight/api.py").read_text(encoding="utf-8")
@@ -0,0 +1,373 @@
from pathlib import Path
import pytest
from gfa_core.extension_stage_lightweight.command_model import build_command_model
def _file_state(*paths: str) -> dict[str, dict[str, object]]:
return {path: {"kind": "file", "size": 1, "sha256": path} for path in paths}
def test_direct_command_retains_all_flags_and_binds_input(tmp_path: Path) -> None:
model = build_command_model(
command="iqtree -s Ft.SPL.pep.mfa -m MFP -bb 1000 -nt 4 -pre Ft.SPL.iqtree",
work_dir=tmp_path,
before_files=_file_state("Ft.SPL.pep.mfa"),
output_files=["Ft.SPL.iqtree.treefile", "Ft.SPL.iqtree.iqtree"],
code_files=[],
)
assert model["source_scripts"] == []
assert len(model["commands"]) == 1
command = model["commands"][0]
assert command["tool"] == "iqtree"
assert [item["flag"] for item in command["arguments"]] == [
"-s",
"-m",
"-bb",
"-nt",
"-pre",
]
assert command["inputs"] == [
{
"path": "Ft.SPL.pep.mfa",
"source": "baseline_input",
"flag": "-s",
"token_index": 2,
}
]
assert [(item["flag"], item["value"], item["kind"]) for item in command["parameters"]] == [
("-m", "MFP", "flag_value"),
("-bb", "1000", "flag_value"),
("-nt", "4", "flag_value"),
("-pre", "Ft.SPL.iqtree", "flag_value"),
]
assert command["outputs"] == [
"Ft.SPL.iqtree.iqtree",
"Ft.SPL.iqtree.treefile",
]
def test_shell_script_is_expanded_per_command_with_switch_and_generated_input(
tmp_path: Path,
) -> None:
script = tmp_path / "run.sh"
script.write_text(
"""#!/usr/bin/env bash
hmmsearch --cut_tc \\
--domtblout Ft.SPL.domtblout \\
-o Ft.SPL.hmmout \\
SBP.hmm Ft.pep.fasta
python parse_hmmer.py Ft.SPL.domtblout > parsed.tsv
""",
encoding="utf-8",
)
(tmp_path / "parse_hmmer.py").write_text("", encoding="utf-8")
model = build_command_model(
command="bash run.sh",
work_dir=tmp_path,
before_files=_file_state(
"run.sh", "parse_hmmer.py", "SBP.hmm", "Ft.pep.fasta"
),
output_files=["Ft.SPL.domtblout", "Ft.SPL.hmmout", "parsed.tsv"],
code_files=["run.sh", "parse_hmmer.py"],
)
assert model["source_scripts"] == ["run.sh"]
assert [item["tool"] for item in model["commands"]] == ["hmmsearch", "python"]
search, parser = model["commands"]
assert [(item["path"], item["source"]) for item in search["inputs"]] == [
("SBP.hmm", "baseline_input"),
("Ft.pep.fasta", "baseline_input"),
]
assert [(item["flag"], item["kind"], item["value"]) for item in search["parameters"]] == [
("--cut_tc", "switch", "--cut_tc"),
("--domtblout", "flag_value", "Ft.SPL.domtblout"),
("-o", "flag_value", "Ft.SPL.hmmout"),
]
assert search["outputs"] == ["Ft.SPL.domtblout", "Ft.SPL.hmmout"]
assert parser["source_files"] == ["parse_hmmer.py"]
assert parser["inputs"] == [
{
"path": "Ft.SPL.domtblout",
"source": "generated_input",
"flag": None,
"token_index": 2,
}
]
assert parser["outputs"] == ["parsed.tsv"]
assert all(item["path"] != "run.sh" for item in parser["inputs"])
def test_output_binding_uses_observed_artifact_not_known_flag_name(tmp_path: Path) -> None:
model = build_command_model(
command="unknown_tool --destination result.custom input.weird",
work_dir=tmp_path,
before_files=_file_state("input.weird"),
output_files=["result.custom"],
code_files=[],
)
command = model["commands"][0]
assert command["parameters"] == [
{
"kind": "flag_value",
"flag": "--destination",
"value": "result.custom",
"token_index": 1,
"value_role": "output_reference",
}
]
assert command["outputs"] == ["result.custom"]
assert command["inputs"][0]["path"] == "input.weird"
@pytest.mark.parametrize("quote", [pytest.param("'", id="single"), pytest.param('"', id="double")])
def test_quoted_value_before_known_input_preserves_ambiguous_grouping(
tmp_path: Path, quote: str,
) -> None:
model = build_command_model(
command=f"unknown_tool -x {quote}rewrite program{quote} input.txt",
work_dir=tmp_path,
before_files=_file_state("input.txt"),
output_files=[],
code_files=[],
)
command = model["commands"][0]
assert command["arguments"] == [
{
"kind": "switch",
"flag": "-x",
"value": "-x",
"token_index": 1,
"grouping_confidence": "ambiguous",
}
]
assert command["parameters"] == [
{
"kind": "switch",
"flag": "-x",
"value": "-x",
"token_index": 1,
"grouping_confidence": "ambiguous",
},
{
"kind": "positional",
"flag": None,
"value": "rewrite program",
"token_index": 2,
"position": 0,
"value_role": "value",
"grouping_confidence": "ambiguous",
},
]
assert command["inputs"] == [
{
"path": "input.txt",
"source": "baseline_input",
"flag": None,
"token_index": 3,
}
]
def test_quoted_candidate_without_later_input_keeps_flag_value(tmp_path: Path) -> None:
model = build_command_model(
command="unknown_tool -x 'rewrite program'",
work_dir=tmp_path,
before_files={},
output_files=[],
code_files=[],
)
command = model["commands"][0]
assert command["arguments"] == [
{
"kind": "flag_value",
"flag": "-x",
"value": "rewrite program",
"token_index": 1,
"value_role": "value",
}
]
assert command["parameters"] == command["arguments"]
@pytest.mark.parametrize(
("candidate", "before_paths", "output_files", "code_files", "value_role"),
[
pytest.param(
"known input.txt",
("known input.txt", "later.txt"),
[],
[],
"input",
id="baseline-input",
),
pytest.param(
"script file.py",
("script file.py", "later.txt"),
[],
["script file.py"],
"value",
id="code-file",
),
pytest.param(
"result file.txt",
("later.txt",),
["result file.txt"],
[],
"output_reference",
id="observed-output",
),
],
)
def test_quoted_candidate_reference_keeps_flag_value(
tmp_path: Path,
candidate: str,
before_paths: tuple[str, ...],
output_files: list[str],
code_files: list[str],
value_role: str,
) -> None:
model = build_command_model(
command=f"unknown_tool -x '{candidate}' later.txt",
work_dir=tmp_path,
before_files=_file_state(*before_paths),
output_files=output_files,
code_files=code_files,
)
command = model["commands"][0]
assert command["arguments"][0] == {
"kind": "flag_value",
"flag": "-x",
"value": candidate,
"token_index": 1,
"value_role": value_role,
}
assert "grouping_confidence" not in command["arguments"][0]
assert any(item["path"] == "later.txt" for item in command["inputs"])
def test_unaligned_posix_and_non_posix_tokens_keep_flag_value(tmp_path: Path) -> None:
model = build_command_model(
command=r"unknown_tool -x rewrite\ program input.txt",
work_dir=tmp_path,
before_files=_file_state("input.txt"),
output_files=[],
code_files=[],
)
command = model["commands"][0]
assert command["arguments"][0] == {
"kind": "flag_value",
"flag": "-x",
"value": "rewrite program",
"token_index": 1,
"value_role": "value",
}
assert "grouping_confidence" not in command["arguments"][0]
def test_escaped_literal_quotes_in_argv_are_not_outer_shell_quotes(
tmp_path: Path,
) -> None:
model = build_command_model(
command=r'unknown_tool -x \"rewrite\ program\" input.txt',
work_dir=tmp_path,
before_files=_file_state("input.txt"),
output_files=[],
code_files=[],
)
command = model["commands"][0]
assert command["arguments"][0] == {
"kind": "flag_value",
"flag": "-x",
"value": '"rewrite program"',
"token_index": 1,
"value_role": "value",
}
assert "grouping_confidence" not in command["arguments"][0]
assert command["inputs"][0]["path"] == "input.txt"
def test_consecutive_ambiguous_pairs_keep_all_tokens_and_positions(
tmp_path: Path,
) -> None:
model = build_command_model(
command="unknown_tool -x 'first program' -y 'second program' input.txt",
work_dir=tmp_path,
before_files=_file_state("input.txt"),
output_files=[],
code_files=[],
)
command = model["commands"][0]
assert [
(
item["kind"],
item["flag"],
item["value"],
item.get("position"),
item.get("grouping_confidence"),
)
for item in command["parameters"]
] == [
("switch", "-x", "-x", None, "ambiguous"),
("positional", None, "first program", 0, "ambiguous"),
("switch", "-y", "-y", None, "ambiguous"),
("positional", None, "second program", 1, "ambiguous"),
]
assert [(item["flag"], item["token_index"]) for item in command["arguments"]] == [
("-x", 1),
("-y", 3),
]
assert command["inputs"][0]["token_index"] == 5
def test_generated_input_evidence_excludes_candidate_and_triggers_later_pair(
tmp_path: Path,
) -> None:
model = build_command_model(
command=(
"unknown_tool input.txt > 'generated candidate.txt' && "
"unknown_tool -x 'generated candidate.txt' input.txt && "
"unknown_tool input.txt > generated_later.txt && "
"unknown_tool -x 'rewrite program' generated_later.txt"
),
work_dir=tmp_path,
before_files=_file_state("input.txt"),
output_files=["generated candidate.txt", "generated_later.txt"],
code_files=[],
)
candidate_command = model["commands"][1]
assert candidate_command["arguments"][0]["kind"] == "flag_value"
assert candidate_command["arguments"][0]["value_role"] == "input"
assert "grouping_confidence" not in candidate_command["arguments"][0]
assert candidate_command["inputs"][0]["source"] == "generated_input"
later_command = model["commands"][3]
assert [item["kind"] for item in later_command["parameters"]] == [
"switch",
"positional",
]
assert all(
item["grouping_confidence"] == "ambiguous"
for item in later_command["parameters"]
)
assert later_command["inputs"] == [
{
"path": "generated_later.txt",
"source": "generated_input",
"flag": None,
"token_index": 3,
}
]
@@ -0,0 +1,287 @@
from __future__ import annotations
from copy import deepcopy
import importlib
import pytest
def _module():
return importlib.import_module(
"gfa_core.extension_stage_lightweight.confirmation"
)
def _facts_and_candidate() -> tuple[dict, dict]:
facts = {
"facts_sha256": "facts-v1",
"commands": [
{
"inputs": [{"fact_id": "step_001:input:001"}],
"parameters": [{"fact_id": "step_001:parameter:001"}],
}
],
"outputs": {
"files": [{"fact_id": "output:file:001"}],
"stdout": {"present": False},
},
}
candidate = {
"facts_sha256": "facts-v1",
"function_summary": "Search sequences with a fixed HMM profile.",
"inputs": [
{
"fact_id": "step_001:input:001",
"role": "hmm_profile",
"description": "HMM profile used by the search.",
"replaceability": "fixed_resource",
}
],
"parameters": [
{
"fact_id": "step_001:parameter:001",
"display_name": "trusted cutoff",
"description": "Use the trusted cutoff stored in the model.",
}
],
"outputs": [
{
"fact_id": "output:file:001",
"description": "Domain table produced by the search.",
"result_role": "final",
}
],
}
return facts, candidate
def test_confirm_revise_and_cancel_have_distinct_non_ambiguous_states() -> None:
module = _module()
facts, candidate = _facts_and_candidate()
repair_history = [
{
"attempt": 1,
"status": "success",
"diff": [
{
"section": "inputs",
"fact_id": "step_001:input:001",
"field": "replaceability",
"before": "replaceable",
"after": "fixed_resource",
}
],
}
]
confirmation = module.create_confirmation(
execution_facts=facts,
candidate=candidate,
repair_history=repair_history,
)
assert confirmation["status"] == "pending"
assert confirmation["accepted"] is False
assert confirmation["facts_sha256"] == "facts-v1"
assert len(confirmation["candidate_sha256"]) == 64
assert confirmation["candidate_version"].startswith("semantic_")
assert confirmation["summary"] == {
"function_summary": candidate["function_summary"],
"input_count": 1,
"parameter_count": 1,
"output_count": 1,
"revision_diff": repair_history[0]["diff"],
}
confirmed = module.apply_confirmation_action(
confirmation=confirmation,
action="confirm",
execution_facts=facts,
candidate=candidate,
)
assert confirmed["status"] == "confirmed"
assert confirmed["accepted"] is True
assert module.is_candidate_accepted(
confirmation=confirmed,
execution_facts=facts,
candidate=candidate,
)
cancelled = module.apply_confirmation_action(
confirmation=confirmation,
action="cancel",
execution_facts=facts,
candidate=candidate,
)
assert cancelled["status"] == "cancelled"
assert cancelled["accepted"] is False
revised = module.apply_confirmation_action(
confirmation=confirmation,
action="revise",
execution_facts=facts,
candidate=candidate,
feedback="HMM 文件应标记为固定资源",
revision_target="inputs.replaceability",
)
assert revised["status"] == "revision_requested"
assert revised["accepted"] is False
assert revised["route"] == "semantic_repair"
assert revised["feedback"] == "HMM 文件应标记为固定资源"
assert confirmation["status"] == "pending"
with pytest.raises(ValueError, match="non-empty feedback"):
module.apply_confirmation_action(
confirmation=confirmation,
action="revise",
execution_facts=facts,
candidate=candidate,
feedback=" ",
revision_target="inputs.description",
)
def test_confirmation_cannot_be_reused_after_facts_or_candidate_change() -> None:
module = _module()
facts, candidate = _facts_and_candidate()
confirmation = module.create_confirmation(
execution_facts=facts,
candidate=candidate,
)
confirmed = module.apply_confirmation_action(
confirmation=confirmation,
action="confirm",
execution_facts=facts,
candidate=candidate,
)
changed_candidate = deepcopy(candidate)
changed_candidate["function_summary"] = "Revised scientific summary."
stale_candidate = module.apply_confirmation_action(
confirmation=confirmation,
action="confirm",
execution_facts=facts,
candidate=changed_candidate,
)
assert stale_candidate["status"] == "stale"
assert stale_candidate["stale_reason"] == "candidate_changed"
assert stale_candidate["accepted"] is False
assert not module.is_candidate_accepted(
confirmation=confirmed,
execution_facts=facts,
candidate=changed_candidate,
)
changed_facts = deepcopy(facts)
changed_facts["facts_sha256"] = "facts-v2"
stale_facts = module.apply_confirmation_action(
confirmation=confirmation,
action="confirm",
execution_facts=changed_facts,
candidate=candidate,
)
assert stale_facts["status"] == "stale"
assert stale_facts["stale_reason"] == "facts_changed"
assert stale_facts["accepted"] is False
def test_revised_candidate_is_a_new_pending_version() -> None:
module = _module()
facts, candidate = _facts_and_candidate()
original = module.create_confirmation(
execution_facts=facts,
candidate=candidate,
)
revised_candidate = deepcopy(candidate)
revised_candidate["inputs"][0]["description"] = (
"Bundled HMM profile used as a fixed scientific resource."
)
revised = module.create_confirmation(
execution_facts=facts,
candidate=revised_candidate,
repair_history=[
{
"attempt": 1,
"status": "success",
"diff": [
{
"section": "inputs",
"fact_id": "step_001:input:001",
"field": "description",
"before": candidate["inputs"][0]["description"],
"after": revised_candidate["inputs"][0]["description"],
}
],
}
],
)
assert revised["candidate_version"] != original["candidate_version"]
assert revised["status"] == "pending"
assert revised["accepted"] is False
@pytest.mark.parametrize(
"target",
[
"semantic",
"function_summary",
"inputs.role",
"inputs.description",
"inputs.replaceability",
"parameters.display_name",
"parameters.description",
"outputs.description",
"outputs.result_role",
],
)
def test_semantic_revision_targets_return_to_repair(target: str) -> None:
module = _module()
facts, candidate = _facts_and_candidate()
result = module.apply_confirmation_action(
confirmation=module.create_confirmation(
execution_facts=facts,
candidate=candidate,
),
action="revise",
execution_facts=facts,
candidate=candidate,
feedback="请按我的科学语义说明修订",
revision_target=target,
)
assert result["status"] == "revision_requested"
assert result["route"] == "semantic_repair"
assert result["accepted"] is False
@pytest.mark.parametrize(
"target",
[
"execution_facts.original_command",
"execution_facts.path",
"execution_facts.flag",
"execution_facts.value",
"execution_facts.default_value",
"execution_facts.observed_output",
],
)
def test_fact_disputes_require_new_evidence_and_never_enter_deepseek(
target: str,
) -> None:
module = _module()
facts, candidate = _facts_and_candidate()
result = module.apply_confirmation_action(
confirmation=module.create_confirmation(
execution_facts=facts,
candidate=candidate,
),
action="revise",
execution_facts=facts,
candidate=candidate,
feedback="这个执行事实不正确",
revision_target=target,
)
assert result["status"] == "fact_dispute"
assert result["route"] == "recollect_execution_evidence"
assert result["accepted"] is False
@@ -0,0 +1,364 @@
from __future__ import annotations
import importlib
import json
from pathlib import Path
from types import SimpleNamespace
import pytest
from gfa_core.extension_stage_lightweight.converter import build_execution_facts
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
def _module():
return importlib.import_module(
"gfa_core.extension_stage_lightweight.deepseek_semantics"
)
def _write(root: Path, relative: str, content: str | bytes) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
if isinstance(content, bytes):
path.write_bytes(content)
else:
path.write_text(content, encoding="utf-8")
return path
def _facts_and_code(tmp_path: Path) -> tuple[dict, Path]:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"cp input.fa result.txt\nprintf 'baseline complete\\n'\n",
)
_write(raw_code, "large_helper.py", "x = 1\n" * 7000)
_write(raw_code, "compiled.bin", b"\x00\x01\x02\xff")
_write(raw_data, "input.fa", ">a\nAAAA\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
return build_execution_facts(evidence), raw_code
def _candidate(facts: dict) -> dict:
output_facts = list(facts["outputs"]["files"])
if facts["outputs"]["stdout"]["present"]:
output_facts.append(facts["outputs"]["stdout"])
return {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Copy a FASTA file and report completion.",
"inputs": [
{
"fact_id": item["fact_id"],
"role": "input_sequences",
"description": "Uploaded FASTA sequences.",
"replaceability": "replaceable",
}
for command in facts["commands"]
for item in command["inputs"]
],
"parameters": [
{
"fact_id": item["fact_id"],
"display_name": "explicit command value",
"description": "Value present in the original command.",
}
for command in facts["commands"]
for item in command["parameters"]
],
"outputs": [
{
"fact_id": item["fact_id"],
"description": "Observed analysis output.",
"result_role": "final",
}
for item in output_facts
],
}
def _env_file(tmp_path: Path, *, include_key: bool = True) -> Path:
lines = [
"NEW_API_BASE_URL=https://api.example.test/v1",
"NEW_API_MODEL=deepseek-test",
]
if include_key:
lines.append("NEW_API_KEY=secret-test-key")
return _write(tmp_path, ".env", "\n".join(lines) + "\n")
class _FakeCompletions:
def __init__(self, response=None, error: Exception | None = None) -> None:
self.response = response
self.error = error
self.requests: list[dict] = []
def create(self, **kwargs):
self.requests.append(kwargs)
if self.error is not None:
raise self.error
return self.response
class _FakeClient:
def __init__(self, response=None, error: Exception | None = None) -> None:
self.completions = _FakeCompletions(response=response, error=error)
self.chat = SimpleNamespace(completions=self.completions)
def _response(content: str):
return SimpleNamespace(
id="request-123",
model="deepseek-test",
choices=[
SimpleNamespace(
message=SimpleNamespace(content=content), finish_reason="stop"
)
],
usage=SimpleNamespace(
prompt_tokens=120,
completion_tokens=80,
total_tokens=200,
),
)
@pytest.mark.parametrize(
("environ", "create_file", "expected_fragment"),
[
({}, False, "BBAV5_PROJECT_ENV_FILE"),
({"BBAV5_PROJECT_ENV_FILE": "missing.env"}, False, "does not exist"),
({"BBAV5_PROJECT_ENV_FILE": "configured.env"}, True, "API key"),
],
)
def test_configuration_requires_one_explicit_project_env_file(
tmp_path: Path,
environ: dict[str, str],
create_file: bool,
expected_fragment: str,
) -> None:
module = _module()
resolved = dict(environ)
if "BBAV5_PROJECT_ENV_FILE" in resolved:
env_path = tmp_path / resolved["BBAV5_PROJECT_ENV_FILE"]
resolved["BBAV5_PROJECT_ENV_FILE"] = str(env_path)
if create_file:
_env_file(tmp_path, include_key=False).rename(env_path)
with pytest.raises(module.DeepSeekSemanticError) as raised:
module.load_deepseek_config(resolved)
assert raised.value.code == "configuration_error"
assert expected_fragment in str(raised.value)
def test_success_request_contains_locked_facts_and_bounded_code_preview(
tmp_path: Path,
) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
candidate = _candidate(facts)
api_candidate = dict(candidate)
api_candidate.pop("facts_sha256")
fake = _FakeClient(response=_response(json.dumps(api_candidate)))
env_file = _env_file(tmp_path)
actual, metadata = module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
client_factory=lambda config: fake,
attempt=3,
)
assert actual == candidate
request = fake.completions.requests[0]
assert request["model"] == "deepseek-test"
assert request["temperature"] == 0
assert request["max_tokens"] == 32000
assert request["response_format"] == {"type": "json_object"}
assert request["extra_body"] == {"thinking": {"type": "enabled"}}
assert metadata["finish_reason"] == "stop"
system_message = request["messages"][0]["content"]
assert "must not return or modify execution facts" in system_message
assert (
"exactly one entry for every supplied input, parameter, and output fact_id"
in system_message
)
assert "no omissions, duplicates, or extras" in system_message
assert "final means" in system_message
assert "intermediate means" in system_message
assert "unknown only" in system_message
payload = json.loads(request["messages"][1]["content"])
assert payload["execution_facts"] == facts
assert "facts_sha256" not in payload["required_candidate_shape"]
assert "generated_input" in payload["required_candidate_shape"]["inputs"][0][
"replaceability"
]
assert payload["execution_facts"]["outputs"]["stdout"]["preview"] == (
"baseline complete"
)
previews = {item["path"]: item for item in payload["raw_code_preview"]["files"]}
assert previews["run.sh"]["kind"] == "text"
assert "content" in previews["run.sh"]
assert previews["large_helper.py"]["truncated"] is True
assert previews["compiled.bin"]["kind"] == "binary"
assert "content" not in previews["compiled.bin"]
assert "large_helper.py" in payload["raw_code_preview"]["truncated_files"]
assert metadata["model"] == "deepseek-test"
assert metadata["base_url"] == "https://api.example.test/v1"
assert metadata["api_key_alias"] == "NEW_API_KEY"
assert metadata["request_id"] == "request-123"
assert metadata["attempt"] == 3
assert metadata["usage"]["total_tokens"] == 200
assert "secret-test-key" not in json.dumps(request)
assert "secret-test-key" not in json.dumps(metadata)
def test_api_cannot_override_system_bound_facts_digest(tmp_path: Path) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
api_candidate = _candidate(facts)
api_candidate["facts_sha256"] = "invented-digest"
fake = _FakeClient(response=_response(json.dumps(api_candidate)))
actual, metadata = module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
client_factory=lambda config: fake,
)
assert actual["facts_sha256"] == facts["facts_sha256"]
assert metadata["discarded_fact_fields"] == ["facts_sha256"]
def test_non_json_response_is_reported_separately(tmp_path: Path) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
fake = _FakeClient(response=_response("not json"))
with pytest.raises(module.DeepSeekSemanticError) as raised:
module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
client_factory=lambda config: fake,
)
assert raised.value.code == "invalid_json"
assert raised.value.metadata["raw_response"] == "not json"
def test_missing_input_replaceability_is_bound_from_locked_input_source(
tmp_path: Path,
) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
api_candidate = _candidate(facts)
api_candidate.pop("facts_sha256")
for item in api_candidate["inputs"]:
item.pop("replaceability")
fake = _FakeClient(response=_response(json.dumps(api_candidate)))
actual, metadata = module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
client_factory=lambda config: fake,
)
assert {item["replaceability"] for item in actual["inputs"]} == {
"replaceable"
}
assert {
item["action"] for item in metadata["fact_derived_semantic_bindings"]
} == {"bind_missing_input_replaceability"}
def test_semantic_gate_error_is_not_reported_as_api_error(tmp_path: Path) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
invalid = _candidate(facts)
invalid["original_command"] = "invented command"
fake = _FakeClient(response=_response(json.dumps(invalid)))
with pytest.raises(module.DeepSeekSemanticError) as raised:
module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
client_factory=lambda config: fake,
)
assert raised.value.code == "semantic_candidate_invalid"
assert raised.value.metadata["system_bound_candidate"]["facts_sha256"] == facts[
"facts_sha256"
]
assert any(
error["field"] == "original_command"
for error in raised.value.metadata["validation_errors"]
)
def test_unknown_model_reference_is_discarded_before_field_repair(
tmp_path: Path,
) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
invalid = _candidate(facts)
wrong_fact_id = invalid["parameters"][0]["fact_id"]
invalid["outputs"][0]["fact_id"] = wrong_fact_id
fake = _FakeClient(response=_response(json.dumps(invalid)))
with pytest.raises(module.DeepSeekSemanticError) as raised:
module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
client_factory=lambda config: fake,
)
assert raised.value.code == "semantic_candidate_invalid"
assert wrong_fact_id not in {
item["fact_id"]
for item in raised.value.metadata["system_bound_candidate"]["outputs"]
}
assert raised.value.metadata["fact_derived_semantic_bindings"] == [
{
"action": "discard_unknown_reference",
"section": "outputs",
"fact_id": wrong_fact_id,
}
]
assert all(
error["code"] == "missing_fact_id"
for error in raised.value.metadata["validation_errors"]
)
def test_api_error_is_redacted_and_classified(tmp_path: Path) -> None:
module = _module()
facts, raw_code = _facts_and_code(tmp_path)
fake = _FakeClient(error=RuntimeError("rejected secret-test-key"))
with pytest.raises(module.DeepSeekSemanticError) as raised:
module.generate_semantic_candidate(
execution_facts=facts,
raw_code=raw_code,
environ={"BBAV5_PROJECT_ENV_FILE": str(_env_file(tmp_path))},
client_factory=lambda config: fake,
)
assert raised.value.code == "api_request_failed"
assert "secret-test-key" not in str(raised.value)
assert raised.value.metadata["api_key_alias"] == "NEW_API_KEY"
@@ -0,0 +1,99 @@
from pathlib import Path
import pytest
from gfa_core.extension_stage_lightweight.evidence import (
run_raw_baseline,
stage_package,
)
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def test_stage_package_copies_code_and_symlinks_data(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
work = tmp_path / "run" / "work"
source_code = _write(raw_code, "scripts/run.sh", "wc -l input.fa\n")
source_data = _write(raw_data, "input.fa", ">a\nAAAA\n")
staged = stage_package(raw_code=raw_code, raw_data=raw_data, work_dir=work)
assert staged["code_files"] == ["scripts/run.sh"]
assert staged["data_files"] == ["input.fa"]
assert (work / "scripts/run.sh").read_text(encoding="utf-8") == source_code.read_text(
encoding="utf-8"
)
assert not (work / "scripts/run.sh").is_symlink()
assert (work / "input.fa").is_symlink()
assert (work / "input.fa").resolve() == source_data.resolve()
def test_stage_package_rejects_same_relative_path(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_code, "shared.txt", "code\n")
_write(raw_data, "shared.txt", "data\n")
with pytest.raises(ValueError, match=r"shared\.txt"):
stage_package(
raw_code=raw_code,
raw_data=raw_data,
work_dir=tmp_path / "run" / "work",
)
def test_run_raw_baseline_records_new_and_modified_files(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"printf 'result\\n' > result.txt\nprintf 'changed\\n' > existing.txt\nprintf 'done\\n'\n",
)
_write(raw_code, "existing.txt", "before\n")
_write(raw_data, "input.fa", ">a\nAAAA\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
assert evidence["command"] == "bash run.sh"
assert evidence["returncode"] == 0
assert evidence["success"] is True
assert evidence["outputs"]["new_files"] == ["result.txt"]
assert evidence["outputs"]["modified_files"] == ["existing.txt"]
assert evidence["outputs"]["stdout"]["present"] is True
assert evidence["outputs"]["stdout"]["preview"] == "done"
assert Path(evidence["logs"]["stdout"]).read_text(encoding="utf-8") == "done\n"
assert Path(evidence["logs"]["stderr"]).is_file()
assert evidence["before_files"]["input.fa"]["kind"] == "symlink"
assert evidence["after_files"]["result.txt"]["size"] > 0
def test_stdout_only_success_is_a_valid_output(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_code, "count.sh", "grep -c '^>' input.fa\n")
_write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
evidence = run_raw_baseline(
command="bash count.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
assert evidence["success"] is True
assert evidence["outputs"]["new_files"] == []
assert evidence["outputs"]["modified_files"] == []
assert evidence["outputs"]["stdout"]["present"] is True
assert evidence["outputs"]["stdout"]["preview"] == "2"
@@ -0,0 +1,123 @@
from __future__ import annotations
from copy import deepcopy
from pathlib import Path
from gfa_core.extension_stage_lightweight import converter
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _two_step_evidence(tmp_path: Path) -> dict:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"cp model.hmm intermediate.domtblout\n"
"python parse.py intermediate.domtblout > parsed.tsv\n",
)
_write(
raw_code,
"parse.py",
"from pathlib import Path\n"
"import sys\n"
"print(Path(sys.argv[1]).read_text(encoding='utf-8').strip())\n",
)
_write(raw_data, "model.hmm", "MODEL\n")
return run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
def test_build_execution_facts_locks_package_and_assigns_stable_ids(
tmp_path: Path,
) -> None:
evidence = _two_step_evidence(tmp_path)
original = deepcopy(evidence)
facts = converter.build_execution_facts(evidence)
assert evidence == original
assert facts["original_command"] == "bash run.sh"
assert facts["package"]["code_files"] == ["parse.py", "run.sh"]
assert facts["package"]["data_files"] == ["model.hmm"]
assert facts["package"]["inventory"]["run.sh"]["source"] == "raw_code"
assert facts["package"]["inventory"]["model.hmm"]["source"] == "raw_test_data"
assert len(facts["package"]["inventory"]["model.hmm"]["sha256"]) == 64
first, second = facts["commands"]
assert first["step_id"] == "step_001"
assert second["step_id"] == "step_002"
assert first["inputs"][0]["fact_id"] == "step_001:input:001"
assert first["parameters"][0]["fact_id"] == "step_001:parameter:002"
assert second["inputs"][0]["source"] == "generated_input"
assert second["inputs"][0]["path"] == "intermediate.domtblout"
assert second["inputs"][0]["fact_id"] == "step_002:input:002"
outputs = {item["path"]: item for item in facts["outputs"]["files"]}
assert outputs["intermediate.domtblout"]["producer_step_ids"] == ["step_001"]
assert outputs["parsed.tsv"]["producer_step_ids"] == ["step_002"]
assert outputs["intermediate.domtblout"]["fact_id"] == "output:file:001"
assert outputs["parsed.tsv"]["fact_id"] == "output:file:002"
assert len(facts["facts_sha256"]) == 64
def test_absent_stdout_is_not_exposed_as_a_semantic_fact(tmp_path: Path) -> None:
facts = converter.build_execution_facts(_two_step_evidence(tmp_path))
assert facts["outputs"]["stdout"]["present"] is False
assert "fact_id" not in facts["outputs"]["stdout"]
def test_build_execution_facts_is_deterministic_for_same_evidence(
tmp_path: Path,
) -> None:
evidence = _two_step_evidence(tmp_path)
first = converter.build_execution_facts(evidence)
second = converter.build_execution_facts(evidence)
assert first == second
assert first["facts_sha256"] == second["facts_sha256"]
def test_build_execution_facts_keeps_unmatched_outputs_without_guessing(
tmp_path: Path,
) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_code, "run.sh", "python make_output.py\n")
_write(
raw_code,
"make_output.py",
"from pathlib import Path\n"
"Path('dynamically_named.txt').write_text('result\\n', encoding='utf-8')\n",
)
raw_data.mkdir()
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
facts = converter.build_execution_facts(evidence)
assert facts["outputs"]["files"] == [
{
"fact_id": "output:file:001",
"path": "dynamically_named.txt",
"producer_step_ids": [],
}
]
assert facts["unmatched_output_fact_ids"] == ["output:file:001"]
@@ -0,0 +1,250 @@
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parents[2]
FRONTEND = ROOT / "apps" / "web" / "vue3" / "index.html"
def _source() -> str:
return FRONTEND.read_text(encoding="utf-8")
def test_lightweight_extension_frontend_uses_new_api_and_conversation_cards() -> None:
source = _source()
assert "/api/lightweight-extension-stages/upload" in source
assert "lightweightExtensionConversation" in source
assert "拓展分析候选核对" in source
assert "提出修订意见" in source
assert "授权发布" in source
def test_lightweight_extension_frontend_deduplicates_operations_and_cards() -> None:
source = _source()
assert "lightweightOperationPromises" in source
assert "lightweightConversationCardKeys" in source
assert "operation_token" in source
assert "card_id" in source
def test_lightweight_published_run_button_does_not_pass_pointer_event_as_extension_id() -> None:
source = _source()
assert '@click="openLightweightRuntimeCard()"' in source
def test_lightweight_extension_frontend_keeps_running_controls_and_stops_lightweight_operation() -> None:
source = _source()
assert "isExecuting.value = true" in source
assert "Polling" in source
assert "⏹ Stop" in source
assert "/operations/${encodeURIComponent(operationToken)}/stop" in source
assert "setInterval" in source
def test_lightweight_extension_frontend_does_not_route_new_submission_through_legacy_gates() -> None:
source = _source()
start = source.index("const buildLightweightExtensionStage")
end = source.index("const buildExtensionStage", start)
lightweight = source[start:end]
assert "/api/extension-stages/build" not in lightweight
assert "nl-runtime-gate" not in lightweight
assert "frontend-e2e-gate" not in lightweight
def test_lightweight_extension_frontend_accepts_command_or_code_with_required_data() -> None:
source = _source()
start = source.index("const buildLightweightExtensionStage")
end = source.index("const confirmLightweightCandidate", start)
upload = source[start:end]
assert "const hasCommand = Boolean(String(extensionBuild.command || '').trim());" in upload
assert "const hasCode = extensionBuild.codeFiles.length > 0;" in upload
assert "(!extensionBuild.dataFiles.length && !hasDataPaths) || (!hasCommand && !hasCode)" in upload
assert "请填写名称并提供测试数据文件或服务器路径;原始命令和原始代码至少提供一项。" in upload
assert "if (hasCommand) form.append('original_command', extensionBuild.command);" in upload
assert "请填写名称和原始命令,并上传原始代码与测试数据。" not in upload
def test_lightweight_extension_api_errors_render_structured_details_in_node() -> None:
source = _source()
start = source.index("const extensionApiError")
end = source.index("const extensionApiUrl", start)
script = f"""
const assert = require('assert');
{source[start:end]}
(async () => {{
const objectMessage = await extensionApiError({{
status: 400,
statusText: 'Bad Request',
json: async () => ({{detail: {{phase: 'upload', message: '缺少执行入口'}}}})
}}, '提交失败');
assert.strictEqual(objectMessage, '提交失败:缺少执行入口');
const validationMessage = await extensionApiError({{
status: 422,
statusText: 'Unprocessable Entity',
json: async () => ({{detail: [{{loc: ['body', 'raw_data_files'], msg: 'Field required'}}]}})
}}, '提交失败');
assert.strictEqual(validationMessage, '提交失败:Field required');
}})().catch((error) => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["/usr/bin/node", "-e", script], text=True, capture_output=True)
assert result.returncode == 0, result.stderr
def test_lightweight_upload_card_uses_command_and_code_neutral_entry_wording() -> None:
source = _source()
start = source.index("const showLightweightCandidate")
end = source.index("const confirmLightweightCandidate", start)
upload = source[start:end]
assert "执行入口已在隔离环境运行" in upload
assert "系统正在确定执行入口,并运行上传的代码和测试数据" in upload
assert "✓ 执行入口已成功复现;候选信息已生成。" in upload
assert "原始命令已在隔离环境执行" not in upload
assert "系统正在以原始命令执行上传的代码和测试数据" not in upload
def test_lightweight_extension_upload_reports_selection_and_server_acceptance() -> None:
source = _source()
handlers_start = source.index("const onExtensionCodeFilesChange")
handlers_end = source.index("const removeExtensionFile", handlers_start)
handlers = source[handlers_start:handlers_end]
upload_start = source.index("const buildLightweightExtensionStage")
upload_end = source.index("const confirmLightweightCandidate", upload_start)
upload = source[upload_start:upload_end]
assert "已选择 ${selected.length} 个原始代码文件" in handlers
assert "已选择 ${selected.length} 个测试数据文件" in handlers
assert "文件上传成功,已开始生成并验收" in upload
assert upload.index("if (!response.ok)") < upload.index("文件上传成功,已开始生成并验收")
def test_lightweight_extension_frontend_accepts_uploaded_or_server_path_data() -> None:
source = _source()
upload_start = source.index("const buildLightweightExtensionStage")
upload_end = source.index("const confirmLightweightCandidate", upload_start)
upload = source[upload_start:upload_end]
assert 'v-model="extensionBuild.dataPaths"' in source
assert "每行填写一个绝对路径" in source
assert "const hasDataPaths = Boolean(String(extensionBuild.dataPaths || '').trim());" in upload
assert "(!extensionBuild.dataFiles.length && !hasDataPaths)" in upload
assert "form.append('raw_data_paths', extensionBuild.dataPaths);" in upload
def test_lightweight_cards_render_in_main_chat_not_extension_form() -> None:
source = _source()
form_start = source.index("生成并验收")
main_chat = source.index('class="agent-chat-history"')
card_renderer = source.index('v-if="lightweightExtensionConversation.cards.length"')
assert 'lightweightExtensionConversation.cards.length' not in source[form_start:main_chat]
assert card_renderer > main_chat
assert 'class="role-badge ai"' in source[card_renderer - 500:card_renderer + 500]
def test_lightweight_revision_uses_main_chat_input_without_inline_editor() -> None:
source = _source()
card_start = source.index('v-if="lightweightExtensionConversation.cards.length"')
card_end = source.index('<!-- 自然语言Input框 -->', card_start)
cards = source[card_start:card_end]
handler_start = source.index("const handleNlInput")
handler_end = source.index("// V2: RAG模式", handler_start)
handler = source[handler_start:handler_end]
assert 'v-model="card.feedback"' not in cards
assert 'v-model="card.revision_target"' not in cards
assert '@click="beginLightweightRevision(card)"' in cards
assert "pendingRevisionCardId" in source
assert "revision_target: 'semantic'" in source
assert "reviseLightweightCandidate(revisionCard, userInput)" in handler
def test_semantic_candidate_failure_offers_semantic_only_retry() -> None:
source = _source()
assert "retry_semantic" in source
assert "retryLightweightSemantic(card)" in source
assert "/retry-semantic" in source
assert "只重新生成说明,不会重新运行原始命令" in source
def test_lightweight_card_and_operation_dedup_helpers_execute_in_node() -> None:
source = _source()
card_start = source.index("const lightweightCardKey")
card_end = source.index("const finishLightweightCard", card_start)
poll_start = source.index("const pollLightweightOperation")
poll_end = source.index("const submitLightweightOperation", poll_start)
script = f"""
const assert = require('assert');
const reactive = (value) => value;
const lightweightExtensionConversation = {{ cards: [] }};
const lightweightConversationCardKeys = new Set();
{source[card_start:card_end]}
const first = addLightweightCard({{ card_id: 'candidate', status: 'pending' }});
const duplicate = addLightweightCard({{ card_id: 'candidate', status: 'pending' }});
assert(first);
assert.strictEqual(duplicate, null);
assert.strictEqual(lightweightExtensionConversation.cards.length, 1);
const lightweightOperationPromises = new Map();
const extensionApiFetch = async () => ({{ ok: true, json: async () => ({{ state: 'completed', status: 'confirmed' }}) }});
const setLightweightRunning = () => {{}};
const clearLightweightRunning = () => {{}};
const forgetLightweightOperation = () => {{}};
const window = {{ setTimeout: (callback) => setTimeout(callback, 0) }};
{source[poll_start:poll_end]}
(async () => {{
const card = {{ loading: true }};
let completed = 0;
const one = pollLightweightOperation('demo', 'same-token', card, async () => {{ completed += 1; }});
const two = pollLightweightOperation('demo', 'same-token', card, async () => {{ completed += 1; }});
assert.strictEqual(one, two);
await one;
assert.strictEqual(completed, 1);
assert.strictEqual(lightweightOperationPromises.size, 0);
}})().catch((error) => {{ console.error(error); process.exit(1); }});
"""
result = subprocess.run(["/usr/bin/node", "-e", script], text=True, capture_output=True)
assert result.returncode == 0, result.stderr
def test_lightweight_frontend_phase_b_closure_helpers_execute_in_one_node_script() -> None:
source = _source()
helper_start = source.index("const isLightweightExtensionId")
helper_end = source.index("const buildLightweightExtensionStage", helper_start)
script = f"""
const assert = require('assert');
const lightweightExtensions = {{ value: [{{ extension_id: 'lightweight_tree', status: 'published' }}] }};
{source[helper_start:helper_end]}
assert.strictEqual(isLightweightExtensionId('lightweight_tree'), true);
assert.strictEqual(isLightweightExtensionId('legacy_tree'), false);
const runs = [{{ run_id: 'run_old' }}, {{ run_id: 'run_new' }}];
assert.deepStrictEqual(lightweightRunById(runs, 'run_old'), {{ run_id: 'run_old' }});
assert.strictEqual(lightweightResumeNext('upload'), 'candidate');
assert.strictEqual(lightweightResumeNext('confirm'), 'validate');
assert.strictEqual(lightweightResumeNext('revise'), 'candidate');
assert.strictEqual(lightweightResumeNext('validate'), 'review');
assert.strictEqual(lightweightResumeNext('review'), 'publish');
assert.strictEqual(lightweightResumeNext('publish'), 'list');
assert.strictEqual(lightweightResumeNext('run'), 'runtime_result');
assert.strictEqual(lightweightCandidateConfirmationSummary({{ revision_summary: 'parameters.description' }}), '✓ 已确认候选|已修订:parameters.description');
assert.strictEqual(lightweightCandidateConfirmationSummary({{}}), '✓ 已确认候选|未作修订');
const rows = lightweightResultRows({{ execution_command: 'bash run.sh', projection: {{ files: [], stdout: {{ present: true, text: '42\\n' }} }}, logs: {{ stdout: 'stdout.log' }} }});
assert.strictEqual(rows.find((row) => row.item === '标准输出').value, '42\\n');
assert.strictEqual(lightweightParameterText({{ flag: '--cut_tc', value: 'true', fact_id: 'p1' }}), '--cut_tc = true');
const parameterRows = lightweightRows({{ inputs: [], parameters: [{{ flag: '--cut_tc', value: 'true', fact_id: 'p1' }}], outputs: {{ stdout: {{ present: false }} }} }});
assert.strictEqual(parameterRows.find((row) => row.item === '可改参数').value, '--cut_tc = true');
"""
result = subprocess.run(["/usr/bin/node", "-e", script], text=True, capture_output=True)
assert result.returncode == 0, result.stderr
intent = source[source.index("const runExtensionStageFromIntent"):source.index("const extensionOutputIcon")]
assert "if (isLightweightExtensionId(extensionId))" in intent
assert intent.index("openLightweightRuntimeCard(extensionId)") < intent.index("executeExtensionStageRun(")
@@ -0,0 +1,583 @@
from __future__ import annotations
import os
import hashlib
import json
from pathlib import Path
import signal
import subprocess
import sys
import threading
import time
import gfa_core.extension_stage_lightweight.process_control as process_module
import pytest
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
from gfa_core.extension_stage_lightweight.process_control import (
start_managed_process,
stop_process_group,
)
from gfa_core.extension_stage_lightweight.runtime_binding import (
render_runtime_package,
replay_runtime_binding,
resolve_runtime_bindings,
)
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _wait_for(path: Path) -> None:
deadline = time.monotonic() + 5
while not path.is_file() and time.monotonic() < deadline:
time.sleep(0.01)
assert path.is_file()
def _pid_is_running(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
return True
def _pid_start_time(pid: int) -> int:
fields = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split()
return int(fields[21])
def _force_stop(identity: dict[str, int]) -> None:
if identity:
try:
os.killpg(identity["pgid"], 9)
except ProcessLookupError:
pass
def _term_ignoring_child_command(child_pid_path: str, child_ready_path: str) -> str:
return (
f"{sys.executable} -c \"import signal, time; "
"signal.signal(signal.SIGTERM, signal.SIG_IGN); "
f"open('{child_ready_path}', 'w').write('ready'); time.sleep(30)\" "
f"& child=$!; echo $child > {child_pid_path}; "
"trap 'exit 0' TERM; while :; do sleep 1; done\n"
)
def _digest(value: object) -> str:
return hashlib.sha256(
json.dumps(value, sort_keys=True, separators=(",", ":")).encode("utf-8")
).hexdigest()
def _render_sleeping_package(tmp_path: Path) -> dict[str, object]:
raw_code = tmp_path / "raw_code"
script = _write(
raw_code,
"run.sh",
_term_ignoring_child_command("child.pid", "child.ready"),
)
script_hash = hashlib.sha256(script.read_bytes()).hexdigest()
contract: dict[str, object] = {
"schema_version": "lightweight-runtime-binding/v1",
"facts_sha256": "facts-locked",
"original_command": "bash run.sh\ntrue",
"source_scripts": [{"path": "run.sh", "sha256": script_hash, "locked": True}],
"package_files": [
{
"path": "run.sh",
"source": "raw_code",
"kind": "file",
"size": script.stat().st_size,
"sha256": script_hash,
"locked": True,
"exposed_as_input": False,
}
],
"inputs": [],
"parameters": [],
"path_references": [],
"outputs": {"files": [], "stdout": {"present": True}},
"patch_targets": [],
"unpatchable_targets": [],
}
contract["package_digest"] = _digest(
{
"run.sh": {
"kind": "file",
"size": script.stat().st_size,
"sha256": script_hash,
"source": "raw_code",
}
}
)
contract["contract_digest"] = _digest(contract)
resolved = resolve_runtime_bindings(contract, {}, {})
assert resolved["status"] == "resolved"
return render_runtime_package(contract, raw_code, resolved, tmp_path / "run")
def test_baseline_cancellation_stops_its_process_group_only(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
child_pid_path = tmp_path / "child.pid"
child_ready_path = tmp_path / "child.ready"
_write(
raw_code,
"run.sh",
_term_ignoring_child_command(str(child_pid_path), str(child_ready_path)),
)
_write(raw_data, "input.txt", "input\n")
started = threading.Event()
identity: dict[str, int] = {}
result: dict[str, object] = {}
def run() -> None:
result["evidence"] = run_raw_baseline(
command="bash run.sh\ntrue",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
on_process_start=lambda value: (identity.update(value), started.set()),
)
thread = threading.Thread(target=run)
unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
try:
thread.start()
assert started.wait(timeout=5)
_wait_for(child_pid_path)
_wait_for(child_ready_path)
child_pid = int(child_pid_path.read_text(encoding="utf-8").strip())
assert identity["pid"] == identity["pgid"]
assert identity["pid_start_time"] > 0
assert stop_process_group(identity) is True
thread.join(timeout=5)
assert not thread.is_alive()
assert result["evidence"]["process"] == identity
assert not _pid_is_running(child_pid)
assert unrelated.poll() is None
finally:
if thread.is_alive():
_force_stop(identity)
thread.join(timeout=5)
unrelated.terminate()
unrelated.wait(timeout=5)
def test_stop_rejects_a_process_group_without_the_original_leader_identity() -> None:
unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
try:
assert stop_process_group(
{
"pid": unrelated.pid,
"pgid": unrelated.pid,
"pid_start_time": 0,
}
) is False
assert unrelated.poll() is None
finally:
unrelated.terminate()
unrelated.wait(timeout=5)
def test_managed_supervisor_anchors_group_and_waits_for_descendants(
tmp_path: Path,
) -> None:
command_pid_path = tmp_path / "command.pid"
release_path = tmp_path / "release"
command = (
f"echo $$ > {command_pid_path}; "
f"while [ ! -f {release_path} ]; do sleep 0.01; done; "
"sleep 0.2 & exit 7"
)
process, identity = start_managed_process(["bash", "-c", command])
try:
_wait_for(command_pid_path)
command_pid = int(command_pid_path.read_text(encoding="utf-8").strip())
assert identity["pid"] != command_pid
assert isinstance(identity["supervisor_token"], str)
started = time.monotonic()
release_path.write_text("release\n", encoding="utf-8")
assert process.wait(timeout=5) == 7
assert time.monotonic() - started >= 0.15
finally:
release_path.touch()
if process.poll() is None:
_force_stop(identity)
process.wait(timeout=5)
def test_stop_rejects_valid_numeric_identity_without_supervisor_token() -> None:
unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
try:
assert stop_process_group(
{
"pid": unrelated.pid,
"pgid": unrelated.pid,
"pid_start_time": _pid_start_time(unrelated.pid),
"supervisor_token": "stale-token",
}
) is False
assert unrelated.poll() is None
finally:
if unrelated.poll() is None:
unrelated.terminate()
unrelated.wait(timeout=5)
def test_start_retries_transient_proc_disappearance(
monkeypatch,
) -> None:
original_read_text = Path.read_text
missing_once = True
def flaky_read_text(path: Path, *args, **kwargs) -> str:
nonlocal missing_once
if missing_once and path.parent.parent == Path("/proc") and path.name == "stat":
missing_once = False
raise FileNotFoundError(path)
return original_read_text(path, *args, **kwargs)
monkeypatch.setattr(Path, "read_text", flaky_read_text)
process, identity = start_managed_process(["bash", "-c", "sleep 0.1; exit 0"])
assert identity["pid"] == identity["pgid"]
assert process.wait(timeout=5) == 0
def test_supervised_command_keeps_default_sigterm_behavior() -> None:
process, identity = start_managed_process(["sleep", "30"])
assert stop_process_group(identity) is True
assert process.wait(timeout=5) == -signal.SIGTERM
def test_supervisor_mirrors_independent_command_sigkill(tmp_path: Path) -> None:
command_pid_path = tmp_path / "command.pid"
command = (
"import os, time; "
f"open('{command_pid_path}', 'w').write(str(os.getpid())); "
"time.sleep(30)"
)
process, _identity = start_managed_process([sys.executable, "-c", command])
_wait_for(command_pid_path)
command_pid = int(command_pid_path.read_text(encoding="utf-8"))
os.kill(command_pid, signal.SIGKILL)
assert process.wait(timeout=5) == -signal.SIGKILL
def test_stop_fails_closed_if_supervisor_disappears_during_grace(
tmp_path: Path,
) -> None:
child_pid_path = tmp_path / "child.pid"
child_ready_path = tmp_path / "child.ready"
command = _term_ignoring_child_command(
str(child_pid_path), str(child_ready_path)
)
process, identity = start_managed_process(["bash", "-c", command])
_wait_for(child_pid_path)
_wait_for(child_ready_path)
child_pid = int(child_pid_path.read_text(encoding="utf-8"))
result: dict[str, bool] = {}
thread = threading.Thread(
target=lambda: result.update(
stopped=stop_process_group(identity, grace_seconds=0.5)
)
)
try:
thread.start()
time.sleep(0.05)
os.kill(int(identity["pid"]), signal.SIGKILL)
thread.join(timeout=5)
assert not thread.is_alive()
assert result["stopped"] is False
assert _pid_is_running(child_pid)
finally:
if _pid_is_running(child_pid):
os.kill(child_pid, signal.SIGKILL)
if process.poll() is None:
os.kill(int(identity["pid"]), signal.SIGKILL)
process.wait(timeout=5)
def test_supervisor_state_rechecks_a_clean_zombie_transition(monkeypatch) -> None:
snapshots = iter(
[
("S", 101, 101, 202),
("Z", 101, 101, 202),
]
)
monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: next(snapshots))
monkeypatch.setattr(process_module, "_is_matching_supervisor", lambda **_kwargs: False)
monkeypatch.setattr(process_module, "_group_membership", lambda *_args: "empty")
assert process_module._supervisor_state(
pid=101,
pgid=101,
start_time=202,
token="token",
) == "exited"
def test_stop_fails_closed_if_sigkill_target_disappears(monkeypatch) -> None:
identity = {
"pid": 101,
"pgid": 101,
"pid_start_time": 202,
"supervisor_token": "token",
}
monkeypatch.setattr(process_module, "_is_matching_supervisor", lambda **_kwargs: True)
monkeypatch.setattr(
process_module, "_wait_for_supervisor_state", lambda **_kwargs: "running"
)
def killpg(_pgid: int, signum: int) -> None:
if signum == signal.SIGKILL:
raise ProcessLookupError
monkeypatch.setattr(process_module.os, "killpg", killpg)
assert stop_process_group(identity) is False
def test_stop_requires_verified_exit_after_sigkill(monkeypatch) -> None:
identity = {
"pid": 101,
"pgid": 101,
"pid_start_time": 202,
"supervisor_token": "token",
}
states = iter(["running", "lost"])
monkeypatch.setattr(process_module, "_is_matching_supervisor", lambda **_kwargs: True)
monkeypatch.setattr(
process_module,
"_wait_for_supervisor_state",
lambda **_kwargs: next(states),
)
monkeypatch.setattr(process_module.os, "killpg", lambda *_args: None)
assert stop_process_group(identity) is False
def test_start_accepts_only_the_execution_context_used_by_callers(
tmp_path: Path,
) -> None:
process_env = os.environ.copy()
process_env["BBAV5_PROCESS_TEST"] = "allowed"
process, _identity = start_managed_process(
["bash", "-c", "printf '%s\\n' \"$BBAV5_PROCESS_TEST\"; pwd; cat"],
cwd=tmp_path,
env=process_env,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
stdout, stderr = process.communicate(b"payload\n", timeout=5)
assert process.returncode == 0
assert stdout.decode().splitlines() == ["allowed", str(tmp_path), "payload"]
assert stderr == b""
@pytest.mark.parametrize(
("group_membership", "expected"),
[("empty", "exited"), ("member", "matching"), ("uncertain", "matching")],
)
def test_post_kill_absent_anchor_requires_an_empty_original_group(
monkeypatch,
group_membership: str,
expected: str,
) -> None:
monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
monkeypatch.setattr(
process_module.os,
"kill",
lambda *_args: (_ for _ in ()).throw(ProcessLookupError()),
)
monkeypatch.setattr(
process_module,
"_group_membership",
lambda *_args: group_membership,
)
assert process_module._supervisor_state(
pid=101,
pgid=101,
start_time=202,
token="token",
verify_absent_exit=True,
) == expected
def test_post_kill_present_zombie_is_not_verified_absent(monkeypatch) -> None:
monkeypatch.setattr(
process_module,
"_read_proc_stat",
lambda _pid: ("Z", 101, 101, 202),
)
monkeypatch.setattr(
process_module, "_group_membership", lambda *_args: "empty"
)
assert process_module._supervisor_state(
pid=101,
pgid=101,
start_time=202,
token="token",
verify_absent_exit=True,
) == "matching"
def test_unreadable_group_member_fails_closed(monkeypatch) -> None:
monkeypatch.setattr(Path, "iterdir", lambda _path: iter([Path("/proc/303")]))
monkeypatch.setattr(Path, "exists", lambda _path: True)
monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
assert process_module._group_membership(101, 202) == "uncertain"
def test_disappeared_unreadable_group_entry_is_skipped(monkeypatch) -> None:
monkeypatch.setattr(Path, "iterdir", lambda _path: iter([Path("/proc/303")]))
monkeypatch.setattr(Path, "exists", lambda _path: False)
monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
assert process_module._group_membership(101, 202) == "empty"
def test_stop_fails_closed_on_proc_stat_permission_error(monkeypatch) -> None:
monkeypatch.setattr(
Path,
"read_text",
lambda *_args, **_kwargs: (_ for _ in ()).throw(PermissionError()),
)
assert stop_process_group(
{
"pid": 101,
"pgid": 101,
"pid_start_time": 202,
"supervisor_token": "token",
}
) is False
def test_stop_fails_closed_on_proc_cmdline_oserror(monkeypatch) -> None:
monkeypatch.setattr(
process_module,
"_read_proc_stat",
lambda _pid: ("S", 101, 101, 202),
)
monkeypatch.setattr(
Path,
"read_bytes",
lambda *_args, **_kwargs: (_ for _ in ()).throw(OSError("unreadable")),
)
assert stop_process_group(
{
"pid": 101,
"pgid": 101,
"pid_start_time": 202,
"supervisor_token": "token",
}
) is False
def test_proc_iteration_permission_error_is_uncertain(monkeypatch) -> None:
monkeypatch.setattr(
Path,
"iterdir",
lambda _path: (_ for _ in ()).throw(PermissionError()),
)
assert process_module._group_membership(101, 202) == "uncertain"
def test_proc_entry_exists_oserror_is_uncertain(monkeypatch) -> None:
monkeypatch.setattr(Path, "iterdir", lambda _path: iter([Path("/proc/303")]))
monkeypatch.setattr(process_module, "_read_proc_stat", lambda _pid: None)
monkeypatch.setattr(
Path,
"exists",
lambda _path: (_ for _ in ()).throw(OSError("unreadable")),
)
assert process_module._group_membership(101, 202) == "uncertain"
def test_start_rejects_unsupported_popen_options_before_launch(
monkeypatch,
) -> None:
monkeypatch.setattr(
process_module.subprocess,
"Popen",
lambda *_args, **_kwargs: pytest.fail("unsupported option reached launch"),
)
unsupported = {
"pass_fds": (1,),
"shell": True,
"executable": "/bin/bash",
"preexec_fn": lambda: None,
"start_new_session": False,
"process_group": 0,
"user": 1000,
"group": 1000,
"extra_groups": [1000],
"umask": 0o022,
}
for option, value in unsupported.items():
with pytest.raises(TypeError, match=option):
start_managed_process(["true"], **{option: value})
def test_replay_reports_process_identity_to_its_start_callback(tmp_path: Path) -> None:
rendered = _render_sleeping_package(tmp_path)
started = threading.Event()
identity: dict[str, int] = {}
result: dict[str, object] = {}
def run() -> None:
result["value"] = replay_runtime_binding(
rendered,
on_process_start=lambda value: (identity.update(value), started.set()),
)
thread = threading.Thread(target=run)
unrelated = subprocess.Popen(["sleep", "30"], start_new_session=True)
thread.start()
assert started.wait(timeout=5)
try:
child_pid_path = Path(rendered["work_dir"]) / "child.pid"
child_ready_path = Path(rendered["work_dir"]) / "child.ready"
_wait_for(child_pid_path)
_wait_for(child_ready_path)
child_pid = int(child_pid_path.read_text(encoding="utf-8").strip())
assert identity["pid"] == identity["pgid"]
assert stop_process_group(identity) is True
thread.join(timeout=5)
assert not thread.is_alive()
assert result["value"]["process"] == identity
assert not _pid_is_running(child_pid)
assert unrelated.poll() is None
finally:
if thread.is_alive():
_force_stop(identity)
thread.join(timeout=5)
unrelated.terminate()
unrelated.wait(timeout=5)
@@ -0,0 +1,62 @@
from pathlib import Path
from gfa_core.extension_stage.raw_package_normalizer import normalize_raw_package
def test_single_shell_upload_becomes_entry_command(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
raw_code.mkdir()
raw_data.mkdir()
script = raw_code / "run.sh"
script.write_text("cp input.fa output.fa\n", encoding="utf-8")
normalized = normalize_raw_package(
explicit_command="",
raw_code=raw_code,
raw_data=raw_data,
saved_code=[script],
)
assert normalized["entry_command"] == "bash run.sh"
assert normalized["entry_source"] == "single_shell_script"
assert normalized["requires_user_confirmation"] is False
def test_multiple_shell_uploads_require_entry_confirmation(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
raw_code.mkdir()
raw_data.mkdir()
(raw_code / "run_a.sh").write_text("echo a\n", encoding="utf-8")
(raw_code / "run_b.sh").write_text("echo b\n", encoding="utf-8")
normalized = normalize_raw_package(
explicit_command="",
raw_code=raw_code,
raw_data=raw_data,
saved_code=sorted(raw_code.iterdir()),
)
assert normalized["entry_source"] == "ambiguous_shell_script"
assert normalized["requires_user_confirmation"] is True
def test_inline_command_is_wrapped_as_run_sh(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
raw_code.mkdir()
raw_data.mkdir()
normalized = normalize_raw_package(
explicit_command="mafft --auto input.fa > output.msa.fa",
raw_code=raw_code,
raw_data=raw_data,
saved_code=[],
)
assert normalized["entry_command"] == "bash run.sh"
assert normalized["entry_source"] == "inline_command_wrapped"
assert (raw_code / "run.sh").read_text(encoding="utf-8").strip().endswith(
"mafft --auto input.fa > output.msa.fa"
)
@@ -0,0 +1,96 @@
import json
from pathlib import Path
from gfa_core.extension_stage_lightweight.converter import (
build_spec,
replay,
write_spec,
write_wrapper,
)
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _baseline(tmp_path: Path) -> tuple[Path, Path, dict[str, object]]:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_data, "input.txt", "baseline\n")
evidence = run_raw_baseline(
command="cp input.txt output.txt",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
return raw_code, raw_data, evidence
def test_build_and_write_spec_is_json_serializable(tmp_path: Path) -> None:
_, _, evidence = _baseline(tmp_path)
spec = build_spec(evidence)
destination = write_spec(spec, tmp_path / "lightweight_spec.json")
loaded = json.loads(destination.read_text(encoding="utf-8"))
assert loaded["schema_version"] == "lightweight-extension/v1"
assert loaded["original_command"] == "cp input.txt output.txt"
assert loaded["baseline"]["returncode"] == 0
assert loaded["outputs"]["files"] == ["output.txt"]
assert loaded["commands"][0]["inputs"][0]["path"] == "input.txt"
def test_wrapper_preserves_original_command(tmp_path: Path) -> None:
_, _, evidence = _baseline(tmp_path)
spec = build_spec(evidence)
wrapper = write_wrapper(spec, tmp_path / "run_extension.sh")
text = wrapper.read_text(encoding="utf-8")
assert "cp input.txt output.txt" in text
assert wrapper.stat().st_mode & 0o111
def test_replay_runs_in_a_new_directory_and_reproduces_output(tmp_path: Path) -> None:
raw_code, raw_data, evidence = _baseline(tmp_path)
spec = build_spec(evidence)
result = replay(
spec=spec,
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "replay",
)
assert result["success"] is True
assert result["contract_passed"] is True
assert result["work_dir"] != evidence["work_dir"]
assert (tmp_path / "replay" / "work" / "output.txt").read_text(
encoding="utf-8"
) == "baseline\n"
def test_replay_input_override_is_bound_by_symlink(tmp_path: Path) -> None:
raw_code, raw_data, evidence = _baseline(tmp_path)
spec = build_spec(evidence)
replacement = _write(tmp_path, "replacement/new.txt", "replacement\n")
result = replay(
spec=spec,
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "replay",
input_overrides={"input.txt": replacement},
)
rebound = tmp_path / "replay" / "work" / "input.txt"
assert result["success"] is True
assert rebound.is_symlink()
assert rebound.resolve() == replacement.resolve()
assert (tmp_path / "replay" / "work" / "output.txt").read_text(
encoding="utf-8"
) == "replacement\n"
@@ -0,0 +1,467 @@
from __future__ import annotations
from copy import deepcopy
import hashlib
import json
from pathlib import Path
import pytest
from gfa_core.extension_stage_lightweight.converter import build_execution_facts
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
from gfa_core.extension_stage_lightweight.runtime_binding import (
build_runtime_binding_contract,
)
def test_package_exports_runtime_binding_public_api() -> None:
from gfa_core.extension_stage_lightweight import (
RuntimeBindingError,
build_runtime_binding_contract as package_build_contract,
render_runtime_package,
replay_runtime_binding,
resolve_runtime_bindings,
)
from gfa_core.extension_stage_lightweight import runtime_binding
assert RuntimeBindingError is runtime_binding.RuntimeBindingError
assert package_build_contract is runtime_binding.build_runtime_binding_contract
assert resolve_runtime_bindings is runtime_binding.resolve_runtime_bindings
assert render_runtime_package is runtime_binding.render_runtime_package
assert replay_runtime_binding is runtime_binding.replay_runtime_binding
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _package(tmp_path: Path) -> tuple[dict, dict, Path]:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"head -n 1 input.fa > intermediate.fa\n"
"cp intermediate.fa result.fa\n",
)
_write(raw_code, "unused_helper.py", "print('locked code')\n")
_write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
facts = build_execution_facts(evidence)
candidate = {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Keep the first FASTA record line.",
"inputs": [
{
"fact_id": item["fact_id"],
"role": "uploaded_sequences",
"description": "User-provided sequence data.",
"replaceability": "fixed_resource",
}
for command in facts["commands"]
for item in command["inputs"]
],
"parameters": [
{
"fact_id": item["fact_id"],
"display_name": "user-facing label",
"description": "A command value.",
}
for command in facts["commands"]
for item in command["parameters"]
],
"outputs": [
{
"fact_id": item["fact_id"],
"description": "Observed result file.",
"result_role": "final",
}
for item in facts["outputs"]["files"]
],
}
return facts, candidate, raw_code
def _by_fact_id(items: list[dict]) -> dict[str, dict]:
return {item["fact_id"]: item for item in items}
def test_contract_locks_code_and_owns_external_and_generated_inputs(
tmp_path: Path,
) -> None:
facts, candidate, raw_code = _package(tmp_path)
contract = build_runtime_binding_contract(facts, candidate, raw_code)
assert contract["schema_version"] == "lightweight-runtime-binding/v1"
assert contract["facts_sha256"] == facts["facts_sha256"]
assert contract["original_command"] == "bash run.sh"
inputs = _by_fact_id(contract["inputs"])
external = next(
item for item in inputs.values() if item["source"] == "baseline_input"
)
generated = next(
item for item in inputs.values() if item["source"] == "generated_input"
)
assert external["path"] == "input.fa"
assert external["replaceable"] is True
assert external["locked"] is False
assert generated["path"] == "intermediate.fa"
assert generated["replaceable"] is False
assert generated["locked"] is True
package_files = {item["path"]: item for item in contract["package_files"]}
assert package_files["run.sh"]["locked"] is True
assert package_files["unused_helper.py"]["locked"] is True
assert package_files["run.sh"]["exposed_as_input"] is False
assert package_files["unused_helper.py"]["exposed_as_input"] is False
assert package_files["input.fa"]["exposed_as_input"] is True
assert not (
{"run.sh", "unused_helper.py"}
& {item["path"] for item in contract["inputs"]}
)
def test_contract_digest_and_source_hash_come_from_locked_package_inventory(
tmp_path: Path,
) -> None:
facts, candidate, raw_code = _package(tmp_path)
contract = build_runtime_binding_contract(facts, candidate, raw_code)
canonical_inventory = json.dumps(
facts["package"]["inventory"],
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
assert contract["package_digest"] == hashlib.sha256(
canonical_inventory
).hexdigest()
assert contract["source_scripts"] == [
{
"path": "run.sh",
"sha256": facts["package"]["inventory"]["run.sh"]["sha256"],
"locked": True,
}
]
assert contract["contract_digest"] == hashlib.sha256(
json.dumps(
{
key: value
for key, value in contract.items()
if key != "contract_digest"
},
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
).hexdigest()
def test_contract_rejects_tampered_non_entry_code_file(tmp_path: Path) -> None:
facts, candidate, raw_code = _package(tmp_path)
(raw_code / "unused_helper.py").write_text(
"print('tampered code')\n", encoding="utf-8"
)
with pytest.raises(
ValueError, match=r"Code file changed after baseline: unused_helper\.py"
):
build_runtime_binding_contract(facts, candidate, raw_code)
def test_contract_exposes_only_explicit_non_path_parameters(tmp_path: Path) -> None:
facts, candidate, raw_code = _package(tmp_path)
contract = build_runtime_binding_contract(facts, candidate, raw_code)
assert [
(item["kind"], item["flag"], item["value"])
for item in contract["parameters"]
] == [("flag_value", "-n", "1")]
assert all(
item.get("value_role") != "output_reference"
for item in contract["parameters"]
)
assert "input.fa" not in {item["value"] for item in contract["parameters"]}
assert "result.fa" not in {item["value"] for item in contract["parameters"]}
assert [item["path"] for item in contract["path_references"]] == [
"result.fa"
]
reference = contract["path_references"][0]
assert reference["value_role"] == "output_reference"
assert any(
target["binding_key"] == f"path_reference:{reference['fact_id']}"
for target in contract["patch_targets"]
)
def test_semantics_can_only_add_text_bound_to_known_fact_ids(tmp_path: Path) -> None:
facts, candidate, raw_code = _package(tmp_path)
baseline_id = next(
item["fact_id"]
for command in facts["commands"]
for item in command["inputs"]
if item["source"] == "baseline_input"
)
parameter_id = next(
item["fact_id"]
for command in facts["commands"]
for item in command["parameters"]
if item.get("value_role") == "value"
)
candidate["original_command"] = "rm -rf /"
candidate["inputs"][0].update(
{"path": "invented.fa", "replaceability": "fixed_resource"}
)
next(
item
for item in candidate["parameters"]
if item["fact_id"] == parameter_id
).update({"flag": "--invented", "value": "invented"})
candidate["outputs"][0]["path"] = "invented.out"
contract = build_runtime_binding_contract(facts, candidate, raw_code)
input_item = _by_fact_id(contract["inputs"])[baseline_id]
parameter_item = _by_fact_id(contract["parameters"])[parameter_id]
assert contract["original_command"] == facts["original_command"]
assert input_item["path"] == "input.fa"
assert input_item["replaceable"] is True
assert input_item["role"] == "uploaded_sequences"
assert input_item["description"] == "User-provided sequence data."
assert parameter_item["flag"] == "-n"
assert parameter_item["value"] == "1"
assert parameter_item["display_name"] == "user-facing label"
assert all(
item["path"] != "invented.out"
for item in contract["outputs"]["files"]
)
def test_shell_patch_targets_record_hash_literal_kind_and_exact_count(
tmp_path: Path,
) -> None:
facts, candidate, raw_code = _package(tmp_path)
contract = build_runtime_binding_contract(facts, candidate, raw_code)
targets = {
(item["patch_kind"], item["original_literal"]): item
for item in contract["patch_targets"]
}
script_hash = facts["package"]["inventory"]["run.sh"]["sha256"]
assert targets[("input_path", "input.fa")]["expected_exact_match_count"] == 1
assert targets[("parameter_value", "1")]["expected_exact_match_count"] == 1
assert targets[("parameter_value", "1")]["token_index"] == 2
assert targets[("output_path", "result.fa")]["expected_exact_match_count"] == 1
assert (
targets[("output_path", "intermediate.fa")][
"expected_exact_match_count"
]
== 2
)
assert all(item["script_path"] == "run.sh" for item in targets.values())
assert all(item["script_hash"] == script_hash for item in targets.values())
assert all(
{"binding_key", "fact_ids", "anchors"} <= set(item)
and all(
{"step_id", "token_index", "command_digest"} <= set(anchor)
for anchor in item["anchors"]
)
for item in targets.values()
)
result_target = targets[("output_path", "result.fa")]
result_fact = next(
item
for item in facts["outputs"]["files"]
if item["path"] == "result.fa"
)
assert result_target["producer_step_ids"] == result_fact["producer_step_ids"]
assert result_target["step_id"] == "step_002"
assert (
result_target["command_digest"]
== hashlib.sha256(b"cp intermediate.fa result.fa").hexdigest()
)
intermediate_target = targets[("output_path", "intermediate.fa")]
intermediate_fact_ids = {
item["fact_id"]
for command in facts["commands"]
for item in command["inputs"]
if item["path"] == "intermediate.fa"
} | {
item["fact_id"]
for item in facts["outputs"]["files"]
if item["path"] == "intermediate.fa"
}
assert intermediate_target["binding_key"] == "path:intermediate.fa"
assert set(intermediate_target["fact_ids"]) == intermediate_fact_ids
assert {anchor["step_id"] for anchor in intermediate_target["anchors"]} == {
"step_001",
"step_002",
}
def test_same_uploaded_path_in_two_steps_forms_one_safe_path_binding(
tmp_path: Path,
) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"head input.fa > first.txt\n"
"tail input.fa > last.txt\n",
)
_write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
facts = build_execution_facts(evidence)
contract = build_runtime_binding_contract(facts, {}, raw_code)
input_fact_ids = {
item["fact_id"]
for command in facts["commands"]
for item in command["inputs"]
if item["path"] == "input.fa"
}
targets = [
item
for item in contract["patch_targets"]
if item["binding_key"] == "path:input.fa"
]
assert len(input_fact_ids) == 2
assert len(targets) == 1
assert set(targets[0]["fact_ids"]) == input_fact_ids
assert len(targets[0]["anchors"]) == 2
assert targets[0]["expected_exact_match_count"] == 2
def test_inline_flag_value_is_not_patchable_in_v1(tmp_path: Path) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_code, "run.sh", "head --lines=1 input.fa > result.fa\n")
_write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
facts = build_execution_facts(evidence)
contract = build_runtime_binding_contract(facts, {}, raw_code)
parameter_fact = next(
item
for command in facts["commands"]
for item in command["parameters"]
if item.get("flag") == "--lines"
)
assert not any(
item["binding_key"] == f"parameter:{parameter_fact['fact_id']}"
for item in contract["patch_targets"]
)
blocked = next(
item
for item in contract["unpatchable_targets"]
if item["binding_key"] == f"parameter:{parameter_fact['fact_id']}"
)
assert blocked["reason"] == "inline_flag_value_not_supported"
def test_repeated_parameter_literal_across_facts_is_not_patchable(
tmp_path: Path,
) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"head -n 1 input.fa > first.txt\n"
"tail -n 1 input.fa > last.txt\n",
)
_write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
facts = build_execution_facts(evidence)
contract = build_runtime_binding_contract(facts, {}, raw_code)
parameter_fact_ids = {
item["fact_id"]
for command in facts["commands"]
for item in command["parameters"]
if item.get("value") == "1"
}
assert len(parameter_fact_ids) == 2
assert not any(
item["patch_kind"] == "parameter_value"
and item["original_literal"] == "1"
for item in contract["patch_targets"]
)
ambiguous = {
item["fact_ids"][0]
for item in contract["unpatchable_targets"]
if item["patch_kind"] == "parameter_value"
and item["original_literal"] == "1"
and item["reason"] == "source_literal_ambiguous"
}
assert ambiguous == parameter_fact_ids
def test_contract_is_deterministic_and_does_not_mutate_inputs(tmp_path: Path) -> None:
facts, candidate, raw_code = _package(tmp_path)
original_facts = deepcopy(facts)
original_candidate = deepcopy(candidate)
first = build_runtime_binding_contract(facts, candidate, raw_code)
second = build_runtime_binding_contract(facts, candidate, raw_code)
assert first == second
assert facts == original_facts
assert candidate == original_candidate
def test_contract_records_unpatchable_reason_without_guessing_for_direct_command(
tmp_path: Path,
) -> None:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
raw_code.mkdir()
_write(raw_data, "input.fa", ">a\nAAAA\n")
evidence = run_raw_baseline(
command="head -n 1 input.fa > result.fa",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
facts = build_execution_facts(evidence)
contract = build_runtime_binding_contract(facts, {}, raw_code)
assert contract["source_scripts"] == []
assert contract["patch_targets"] == []
assert contract["unpatchable_targets"]
assert {item["reason"] for item in contract["unpatchable_targets"]} == {
"source_literal_not_found_in_raw_shell"
}
@@ -0,0 +1,630 @@
from __future__ import annotations
from copy import deepcopy
import json
from pathlib import Path
import pytest
from gfa_core.extension_stage_lightweight.runtime_binding import (
build_runtime_binding_contract,
resolve_runtime_bindings,
)
CATALOG_ROOT = (
Path(__file__).resolve().parents[2]
/ "benchmarks"
/ "extension_stage_lightweight_gene_family_8x5"
)
def _file(tmp_path: Path, name: str) -> Path:
path = tmp_path / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("test\n", encoding="utf-8")
return path
def _input(
fact_id: str,
path: str,
role: str | None = None,
step_id: str = "step_001",
) -> dict:
item = {
"fact_id": fact_id,
"path": path,
"step_id": step_id,
"source": "baseline_input",
"replaceable": True,
"locked": False,
}
if role:
item["role"] = role
return item
def _contract(
*,
inputs: list[dict],
outputs: list[tuple[str, str]],
parameters: list[dict] | None = None,
stdout: bool = False,
) -> dict:
return {
"schema_version": "lightweight-runtime-binding/v1",
"facts_sha256": "facts-1",
"package_digest": "package-1",
"inputs": inputs,
"parameters": parameters or [],
"outputs": {
"files": [
{
"fact_id": fact_id,
"path": path,
"producer_step_ids": ["step_001"],
}
for fact_id, path in outputs
],
"stdout": {"present": stdout},
},
}
def _outputs(result: dict) -> dict[str, dict]:
return {item["fact_id"]: item for item in result["output_bindings"]}
def test_resolves_single_input_and_output_prefix(tmp_path: Path) -> None:
replacement = _file(tmp_path, "Runtime.Sample.pep.fasta")
contract = _contract(
inputs=[_input("input:1", "Ft.SPL.pep.fasta", "sequences")],
outputs=[("output:1", "Ft.SPL.pep.mfa")],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "resolved"
assert result["error"] is None
assert result["input_bindings"] == [
{
"original_path": "Ft.SPL.pep.fasta",
"runtime_path": str(replacement),
"fact_ids": ["input:1"],
}
]
assert _outputs(result)["output:1"]["runtime_path"] == "output.mfa"
assert result["prefix_binding"]["mode"] == "default"
def test_consistent_multi_input_prefixes_resolve_once(tmp_path: Path) -> None:
first = _file(tmp_path, "Runtime.one.fa")
second = _file(tmp_path, "Runtime.two.fa")
contract = _contract(
inputs=[
_input("input:1", "Ft.one.fa"),
_input("input:2", "Ft.two.fa"),
],
outputs=[("output:1", "Ft.result.txt")],
)
result = resolve_runtime_bindings(
contract, {"input:1": first, "input:2": second}, {}
)
assert result["status"] == "resolved"
assert _outputs(result)["output:1"]["runtime_path"] == "output.result.txt"
def test_unrelated_model_input_does_not_participate_in_output_prefix(
tmp_path: Path,
) -> None:
model = _file(tmp_path, "RuntimeModel.hmm")
sequences = _file(tmp_path, "Runtime.pep.fasta")
contract = _contract(
inputs=[
_input("input:model", "SBP.hmm"),
_input("input:sequences", "Ft.pep.fasta"),
],
outputs=[("output:1", "Ft.domtblout")],
)
result = resolve_runtime_bindings(
contract,
{"input:model": model, "input:sequences": sequences},
{},
)
assert result["status"] == "resolved"
assert _outputs(result)["output:1"]["runtime_path"] == "output.domtblout"
def test_separator_change_without_evidence_uses_default_output_prefix(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Runtime-Reference.collinearity")
contract = _contract(
inputs=[_input("input:1", "Ft-Ath.collinearity")],
outputs=[("output:1", "Ft.Ath.anchors")],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "resolved"
assert result["prefix_binding"] == {
"mode": "default",
"old_prefix": "Ft.Ath",
"new_prefix": "output",
"participating_inputs": [],
}
assert _outputs(result)["output:1"]["runtime_path"] == "output.anchors"
def test_no_input_output_name_relation_uses_default_output_prefix(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Runtime.fa")
contract = _contract(
inputs=[_input("input:1", "input.fa")],
outputs=[("output:1", "result.txt")],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "resolved"
assert result["prefix_binding"]["mode"] == "default"
assert result["prefix_binding"]["old_prefix"] == "result"
assert result["prefix_binding"]["new_prefix"] == "output"
assert _outputs(result)["output:1"]["runtime_path"] == "output.txt"
def test_common_output_prefix_is_replaced_without_input_name_relation(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Runtime.Sample.genomic.gff")
contract = _contract(
inputs=[_input("input:1", "GCF_reference_genomic.gff")],
outputs=[
("output:tmp", "Ath.gff3.tmp"),
("output:gff", "Ath_final.gff3"),
("output:ids", "Ath_gene_mrna_cds.ids"),
],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "resolved"
assert result["prefix_binding"]["old_prefix"] == "Ath"
assert result["prefix_binding"]["new_prefix"] == "output"
assert {
fact_id: item["runtime_path"]
for fact_id, item in _outputs(result).items()
} == {
"output:tmp": "output.gff3.tmp",
"output:gff": "output_final.gff3",
"output:ids": "output_gene_mrna_cds.ids",
}
def test_auxiliary_output_stays_unchanged_after_another_output_is_derived(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Runtime.fa")
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[
("output:main", "Ft.result.txt"),
("output:aux", "summary.log"),
],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "resolved"
assert _outputs(result)["output:main"]["runtime_path"] == "output.result.txt"
assert _outputs(result)["output:aux"] == {
"fact_id": "output:aux",
"original_path": "summary.log",
"runtime_path": "summary.log",
"transformed": False,
}
def test_conflicting_input_prefixes_use_default_and_accept_explicit_prefix(
tmp_path: Path,
) -> None:
first = _file(tmp_path, "X.one.fa")
second = _file(tmp_path, "Y.two.fa")
contract = _contract(
inputs=[
_input("input:1", "Ft.one.fa"),
_input("input:2", "Ft.two.fa"),
],
outputs=[("output:1", "Ft.result.txt")],
)
blocked = resolve_runtime_bindings(
contract, {"input:1": first, "input:2": second}, {}
)
resolved = resolve_runtime_bindings(
contract,
{"input:1": first, "input:2": second},
{},
explicit_prefix="Chosen",
)
assert blocked["status"] == "resolved"
assert blocked["prefix_binding"]["mode"] == "default"
assert _outputs(blocked)["output:1"]["runtime_path"] == "output.result.txt"
assert resolved["status"] == "resolved"
assert resolved["prefix_binding"]["mode"] == "explicit"
assert _outputs(resolved)["output:1"]["runtime_path"] == "Chosen.result.txt"
def test_different_source_prefixes_use_declared_output_with_explicit_prefix(
tmp_path: Path,
) -> None:
first = _file(tmp_path, "X.one.fa")
second = _file(tmp_path, "Y.two.fa")
contract = _contract(
inputs=[
_input("input:1", "Ft.one.fa"),
_input("input:2", "Ft.SPL.two.fa"),
],
outputs=[("output:1", "Ft.SPL.result.txt")],
)
result = resolve_runtime_bindings(
contract,
{"input:1": first, "input:2": second},
{},
explicit_prefix="Chosen",
)
assert result["status"] == "resolved"
assert result["prefix_binding"]["mode"] == "explicit"
assert _outputs(result)["output:1"]["runtime_path"] == "Chosen.txt"
def test_duplicate_input_facts_group_by_path_and_conflicts_are_blocked(
tmp_path: Path,
) -> None:
first = _file(tmp_path, "Runtime.fa")
other = _file(tmp_path, "Other.fa")
contract = _contract(
inputs=[
_input("input:1", "Ft.fa", "sequences"),
_input("input:2", "Ft.fa", "sequences"),
],
outputs=[("output:1", "Ft.out")],
)
grouped = resolve_runtime_bindings(
contract, {"sequences": first}, {}
)
conflict = resolve_runtime_bindings(
contract, {"input:1": first, "input:2": other}, {}
)
assert grouped["status"] == "resolved"
assert grouped["input_bindings"][0]["fact_ids"] == ["input:1", "input:2"]
assert conflict["error"]["code"] == "INPUT_BINDING_CONFLICT"
def test_missing_unknown_and_nonexistent_inputs_have_stable_codes(
tmp_path: Path,
) -> None:
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[("output:1", "Ft.out")],
)
missing = resolve_runtime_bindings(contract, {}, {})
unknown = resolve_runtime_bindings(
contract, {"invented": _file(tmp_path, "Runtime.fa")}, {}
)
nonexistent = resolve_runtime_bindings(
contract, {"input:1": tmp_path / "absent.fa"}, {}
)
assert missing["error"]["code"] == "INPUT_BINDING_MISSING"
assert unknown["error"]["code"] == "INPUT_NOT_DECLARED"
assert nonexistent["error"]["code"] == "INPUT_PATH_NOT_FOUND"
def test_stdout_only_contract_does_not_need_output_prefix(tmp_path: Path) -> None:
replacement = _file(tmp_path, "Runtime.fa")
contract = _contract(
inputs=[_input("input:1", "input.fa")],
outputs=[],
stdout=True,
)
result = resolve_runtime_bindings(
contract, {"input.fa": replacement}, {}
)
assert result["status"] == "resolved"
assert result["prefix_binding"]["mode"] == "none"
assert result["output_bindings"] == []
def test_parameter_overrides_only_declared_values_and_safe_switches(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Ft.fa")
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[("output:1", "Ft.out")],
parameters=[
{
"fact_id": "parameter:model",
"kind": "flag_value",
"flag": "-m",
"value": "MFP",
},
{
"fact_id": "parameter:threshold",
"kind": "switch",
"flag": "--cut_tc",
"value": "--cut_tc",
},
{
"fact_id": "parameter:count",
"kind": "positional",
"flag": None,
"value": "10",
},
],
)
result = resolve_runtime_bindings(
contract,
{"input:1": replacement},
{
"parameter:model": "LG+G4",
"parameter:threshold": "--cut_ga",
"parameter:count": 20,
},
)
bindings = {item["fact_id"]: item for item in result["parameter_bindings"]}
assert result["status"] == "resolved"
assert bindings["parameter:model"]["flag"] == "-m"
assert bindings["parameter:model"]["runtime_value"] == "LG+G4"
assert bindings["parameter:threshold"]["runtime_value"] == "--cut_ga"
assert bindings["parameter:count"]["runtime_value"] == "20"
def test_unknown_and_unsafe_parameter_overrides_are_blocked(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Ft.fa")
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[("output:1", "Ft.out")],
parameters=[
{
"fact_id": "parameter:threshold",
"kind": "switch",
"flag": "--cut_tc",
"value": "--cut_tc",
}
],
)
unknown = resolve_runtime_bindings(
contract, {"input:1": replacement}, {"parameter:other": "x"}
)
whitespace = resolve_runtime_bindings(
contract,
{"input:1": replacement},
{"parameter:threshold": "--cut_ga extra"},
)
control = resolve_runtime_bindings(
contract,
{"input:1": replacement},
{"parameter:threshold": "--cut_ga\nrm"},
)
assert unknown["error"]["code"] == "PARAMETER_NOT_DECLARED"
assert unknown["error"]["available_fact_ids"] == ["parameter:threshold"]
assert whitespace["error"]["code"] == "PARAMETER_VALUE_INVALID"
assert control["error"]["code"] == "PARAMETER_VALUE_INVALID"
def test_resolution_is_deterministic_json_and_does_not_mutate_arguments(
tmp_path: Path,
) -> None:
replacement = _file(tmp_path, "Runtime.fa")
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[("output:1", "Ft.out")],
)
input_overrides = {"input:1": replacement}
parameter_overrides: dict = {}
original_contract = deepcopy(contract)
original_inputs = dict(input_overrides)
first = resolve_runtime_bindings(
contract, input_overrides, parameter_overrides
)
second = resolve_runtime_bindings(
contract, input_overrides, parameter_overrides
)
assert first == second
assert len(first["binding_digest"]) == 64
assert contract == original_contract
assert input_overrides == original_inputs
assert parameter_overrides == {}
def test_dev_contracts_match_all_runtime_binding_gold_cases(
tmp_path: Path,
) -> None:
if not (CATALOG_ROOT / "runtime_binding_dev_gold.json").is_file():
pytest.skip("runtime-binding benchmark data is outside this migration")
gold = json.loads(
(CATALOG_ROOT / "runtime_binding_dev_gold.json").read_text(
encoding="utf-8"
)
)
attempt = json.loads(
(CATALOG_ROOT / "dev_api_results_attempt_007.json").read_text(
encoding="utf-8"
)
)
actual_by_case = {item["case_id"]: item for item in attempt["cases"]}
counts = {"resolved": 0, "blocked": 0}
for expected in gold["cases"]:
case_id = expected["case_id"]
actual = actual_by_case[case_id]
materialized = CATALOG_ROOT / "materialized" / case_id
contract = build_runtime_binding_contract(
actual["execution_facts"],
actual["final_semantic"]["candidate"],
materialized / "raw_code",
)
overrides = {}
for binding in expected["input_overrides"]:
source = materialized / "raw_test_data" / binding["original_path"]
replacement = tmp_path / case_id / binding["replacement_path"]
replacement.parent.mkdir(parents=True, exist_ok=True)
replacement.write_bytes(source.read_bytes())
overrides[binding["fact_id"]] = replacement
result = resolve_runtime_bindings(contract, overrides, {})
counts[result["status"]] += 1
wanted_status = (
"resolved" if expected["expected_status"] == "runnable" else "blocked"
)
assert result["status"] == wanted_status, case_id
if wanted_status == "blocked":
assert result["error"]["code"] == expected["blocked"]["code"], case_id
continue
assert result["prefix_binding"]["old_prefix"] == expected["evidence"]["old_prefix"], case_id
assert result["prefix_binding"]["new_prefix"] == expected["evidence"]["new_prefix"], case_id
actual_outputs = _outputs(result)
for output in expected["expected_output_path_transforms"]:
assert actual_outputs[output["fact_id"]]["runtime_path"] == output["expected_path"], case_id
assert actual_outputs[output["fact_id"]]["transformed"] is output["transformed"], case_id
assert counts == {"resolved": 16, "blocked": 0}
def test_colliding_runtime_output_paths_are_blocked(tmp_path: Path) -> None:
replacement = _file(tmp_path, "Runtime.fa")
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[("output:1", "Ft.out"), ("output:2", "output.out")],
)
result = resolve_runtime_bindings(contract, {"input:1": replacement}, {})
assert result["status"] == "blocked"
assert result["error"]["code"] == "OUTPUT_PATH_COLLISION"
def test_explicit_and_inferred_prefixes_share_strict_validation(
tmp_path: Path,
) -> None:
contract = _contract(
inputs=[_input("input:1", "Ft.fa")],
outputs=[("output:1", "Ft.out")],
)
safe_input = _file(tmp_path, "Runtime.fa")
unsafe_inferred = _file(tmp_path, "Runtime\nName.fa")
inferred = resolve_runtime_bindings(
contract, {"input:1": unsafe_inferred}, {}
)
explicit_results = [
resolve_runtime_bindings(
contract,
{"input:1": safe_input},
{},
explicit_prefix=value,
)
for value in ("with space", "$(touch_x)", "-leading")
]
assert inferred["error"]["code"] == "INVALID_PREFIX"
assert [item["error"]["code"] for item in explicit_results] == [
"INVALID_PREFIX",
"INVALID_PREFIX",
"INVALID_PREFIX",
]
def test_symlink_runtime_name_drives_prefix_without_mutating_inputs(
tmp_path: Path,
) -> None:
target = _file(tmp_path, "old_name.fa")
replacement = tmp_path / "new_name.fa"
replacement.symlink_to(target.name)
contract = _contract(
inputs=[_input("input:1", "old_name.fa")],
outputs=[("output:1", "old_name.out")],
)
overrides = {"input:1": replacement}
original_contract = deepcopy(contract)
original_overrides = dict(overrides)
result = resolve_runtime_bindings(contract, overrides, {})
assert result["status"] == "resolved"
assert Path(result["input_bindings"][0]["runtime_path"]).name == "new_name.fa"
assert result["prefix_binding"]["new_prefix"] == "output"
assert _outputs(result)["output:1"]["runtime_path"] == "output.out"
assert contract == original_contract
assert overrides == original_overrides
def test_symlink_separator_change_uses_default_output_prefix(
tmp_path: Path,
) -> None:
target = _file(tmp_path, "Ft-Ath.collinearity")
replacement = tmp_path / "Runtime-Reference.collinearity"
replacement.symlink_to(target.name)
contract = _contract(
inputs=[_input("input:1", "Ft-Ath.collinearity")],
outputs=[("output:1", "Ft.Ath.anchors")],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "resolved"
assert _outputs(result)["output:1"]["runtime_path"] == "output.anchors"
def test_dangling_runtime_input_symlink_is_rejected(tmp_path: Path) -> None:
replacement = tmp_path / "new_name.fa"
replacement.symlink_to("missing.fa")
contract = _contract(
inputs=[_input("input:1", "old_name.fa")],
outputs=[("output:1", "old_name.out")],
)
result = resolve_runtime_bindings(
contract, {"input:1": replacement}, {}
)
assert result["status"] == "blocked"
assert result["error"]["code"] == "INPUT_PATH_NOT_FOUND"
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,472 @@
from __future__ import annotations
from copy import deepcopy
import importlib
from pathlib import Path
import pytest
from gfa_core.extension_stage_lightweight.converter import build_execution_facts
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _semantic_model():
return importlib.import_module("gfa_core.extension_stage_lightweight.semantic_model")
def _facts(tmp_path: Path) -> dict:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_code, "run.sh", "cp input.fa result.txt\n")
_write(raw_data, "input.fa", ">a\nAAAA\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
return build_execution_facts(evidence)
def _facts_with_generated_input(tmp_path: Path) -> dict:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(
raw_code,
"run.sh",
"cp input.fa intermediate.fa\ncp intermediate.fa result.fa\n",
)
_write(raw_data, "input.fa", ">a\nAAAA\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
return build_execution_facts(evidence)
def _candidate(facts: dict) -> dict:
command = facts["commands"][0]
return {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Copy an uploaded FASTA file to a result file.",
"inputs": [
{
"fact_id": command["inputs"][0]["fact_id"],
"role": "input_sequences",
"description": "Uploaded FASTA sequences.",
"replaceability": "replaceable",
}
],
"parameters": [
{
"fact_id": command["parameters"][0]["fact_id"],
"display_name": "result file",
"description": "Output filename from the original command.",
}
],
"outputs": [
{
"fact_id": facts["outputs"]["files"][0]["fact_id"],
"description": "Copied FASTA result.",
"result_role": "final",
}
],
}
def test_valid_candidate_references_only_locked_fact_ids(tmp_path: Path) -> None:
model = _semantic_model()
facts = _facts(tmp_path)
candidate = _candidate(facts)
assert model.validate_semantic_candidate(facts, candidate) == []
merged = model.merge_semantics(facts, candidate)
assert merged == {
"execution_facts": facts,
"semantics": candidate,
}
assert merged["execution_facts"] is not facts
assert merged["semantics"] is not candidate
def test_candidate_rejects_fact_digest_mismatch(tmp_path: Path) -> None:
model = _semantic_model()
facts = _facts(tmp_path)
candidate = _candidate(facts)
candidate["facts_sha256"] = "0" * 64
errors = model.validate_semantic_candidate(facts, candidate)
assert any(
error["field"] == "facts_sha256" and error["code"] == "facts_mismatch"
for error in errors
)
def test_candidate_rejects_missing_duplicate_and_unknown_fact_ids(
tmp_path: Path,
) -> None:
model = _semantic_model()
facts = _facts(tmp_path)
candidate = _candidate(facts)
input_id = candidate["inputs"][0]["fact_id"]
parameter_id = candidate["parameters"][0]["fact_id"]
candidate["inputs"].append(deepcopy(candidate["inputs"][0]))
candidate["parameters"] = []
candidate["outputs"][0]["fact_id"] = "output:file:999"
errors = model.validate_semantic_candidate(facts, candidate)
assert any(
error["code"] == "duplicate_fact_id" and input_id in error["message"]
for error in errors
)
assert any(
error["code"] == "missing_fact_id" and parameter_id in error["message"]
for error in errors
)
assert any(
error["code"] == "unknown_fact_id" and "output:file:999" in error["message"]
for error in errors
)
def test_candidate_rejects_fields_that_could_rewrite_facts(tmp_path: Path) -> None:
model = _semantic_model()
facts = _facts(tmp_path)
candidate = _candidate(facts)
candidate["original_command"] = "rm -rf result.txt"
candidate["inputs"][0]["path"] = "invented.fa"
candidate["parameters"][0]["flag"] = "--invented"
candidate["outputs"][0]["path"] = "invented.out"
errors = model.validate_semantic_candidate(facts, candidate)
fields = {error["field"] for error in errors if error["code"] == "unknown_field"}
assert fields == {
"original_command",
"inputs[0].path",
"parameters[0].flag",
"outputs[0].path",
}
with pytest.raises(model.SemanticCandidateError) as raised:
model.merge_semantics(facts, candidate)
assert raised.value.errors == errors
def test_semantic_revision_does_not_mutate_execution_facts(tmp_path: Path) -> None:
model = _semantic_model()
facts = _facts(tmp_path)
original_facts = deepcopy(facts)
candidate = _candidate(facts)
revised = deepcopy(candidate)
revised["inputs"][0]["description"] = "A user-selected sequence file."
revised["inputs"][0]["replaceability"] = "fixed_resource"
revised["outputs"][0]["result_role"] = "intermediate"
merged = model.merge_semantics(facts, revised)
assert facts == original_facts
assert merged["execution_facts"] == original_facts
assert merged["execution_facts"]["facts_sha256"] == facts["facts_sha256"]
assert merged["semantics"] == revised
@pytest.mark.parametrize(
("section", "field", "bad_value"),
[
("inputs", "replaceability", "sometimes"),
("outputs", "result_role", "pretty"),
],
)
def test_candidate_rejects_unknown_semantic_enum_values(
tmp_path: Path,
section: str,
field: str,
bad_value: str,
) -> None:
model = _semantic_model()
facts = _facts(tmp_path)
candidate = _candidate(facts)
candidate[section][0][field] = bad_value
errors = model.validate_semantic_candidate(facts, candidate)
assert any(error["code"] == "invalid_value" for error in errors)
def test_candidate_accepts_generated_for_step_output_reused_as_input(
tmp_path: Path,
) -> None:
model = _semantic_model()
facts = _facts_with_generated_input(tmp_path)
inputs = []
parameters = []
for command in facts["commands"]:
for item in command["inputs"]:
inputs.append(
{
"fact_id": item["fact_id"],
"role": "sequence file",
"description": "Sequence data consumed by this step.",
"replaceability": (
"generated"
if item["source"] == "generated_input"
else "replaceable"
),
}
)
for item in command["parameters"]:
parameters.append(
{
"fact_id": item["fact_id"],
"display_name": "output filename",
"description": "Output filename fixed by the original command.",
}
)
candidate = {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Copy a sequence file through an intermediate file.",
"inputs": inputs,
"parameters": parameters,
"outputs": [
{
"fact_id": item["fact_id"],
"description": "Observed file produced by the command.",
"result_role": "final",
}
for item in facts["outputs"]["files"]
],
}
assert any(item["replaceability"] == "generated" for item in inputs)
assert model.validate_semantic_candidate(facts, candidate) == []
def test_candidate_rejects_replaceability_that_conflicts_with_input_source(
tmp_path: Path,
) -> None:
model = _semantic_model()
facts = _facts_with_generated_input(tmp_path)
inputs = []
parameters = []
sources = {}
for command in facts["commands"]:
for item in command["inputs"]:
sources[item["fact_id"]] = item["source"]
inputs.append(
{
"fact_id": item["fact_id"],
"role": "sequence file",
"description": "Sequence data consumed by this step.",
"replaceability": (
"generated"
if item["source"] == "generated_input"
else "replaceable"
),
}
)
for item in command["parameters"]:
parameters.append(
{
"fact_id": item["fact_id"],
"display_name": "output filename",
"description": "Output filename fixed by the original command.",
}
)
candidate = {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Copy a sequence file through an intermediate file.",
"inputs": inputs,
"parameters": parameters,
"outputs": [
{
"fact_id": item["fact_id"],
"description": "Observed file produced by the command.",
"result_role": "final",
}
for item in facts["outputs"]["files"]
],
}
generated_index = next(
index
for index, item in enumerate(candidate["inputs"])
if sources[item["fact_id"]] == "generated_input"
)
external_index = next(
index
for index, item in enumerate(candidate["inputs"])
if sources[item["fact_id"]] != "generated_input"
)
candidate["inputs"][generated_index]["replaceability"] = "replaceable"
candidate["inputs"][external_index]["replaceability"] = "generated"
errors = model.validate_semantic_candidate(facts, candidate)
assert {
error["field"]
for error in errors
if error["code"] == "input_source_mismatch"
} == {
f"inputs[{generated_index}].replaceability",
f"inputs[{external_index}].replaceability",
}
def test_system_binding_makes_invalid_model_references_repairable(
tmp_path: Path,
) -> None:
model = _semantic_model()
facts = _facts_with_generated_input(tmp_path)
inputs = []
parameters = []
for command in facts["commands"]:
for item in command["inputs"]:
inputs.append(
{
"fact_id": item["fact_id"],
"role": "sequence file",
"description": "Sequence data consumed by this step.",
"replaceability": "replaceable",
}
)
for item in command["parameters"]:
parameters.append(
{
"fact_id": item["fact_id"],
"display_name": "output filename",
"description": "Output filename fixed by the original command.",
}
)
candidate = {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Copy a sequence file through an intermediate file.",
"inputs": inputs + [deepcopy(inputs[0])],
"parameters": parameters,
"outputs": [
{
"fact_id": "step_001:parameter:002",
"description": "Wrongly referenced output.",
"result_role": "final",
}
],
}
bound, audit = model.bind_fact_derived_semantics(facts, candidate)
generated_ids = {
item["fact_id"]
for command in facts["commands"]
for item in command["inputs"]
if item["source"] == "generated_input"
}
assert all(
item["replaceability"] == "generated"
for item in bound["inputs"]
if item["fact_id"] in generated_ids
)
assert len(bound["inputs"]) == len(inputs)
assert bound["outputs"] == []
assert {item["action"] for item in audit} == {
"bind_generated_input",
"discard_duplicate_reference",
"discard_unknown_reference",
}
errors = model.validate_semantic_candidate(facts, bound)
assert all(error["code"] == "missing_fact_id" for error in errors)
def test_system_binding_completes_omitted_locked_fact_rows(tmp_path: Path) -> None:
model = _semantic_model()
facts = _facts_with_generated_input(tmp_path)
input_fact = facts["commands"][1]["inputs"][0]
parameter_fact = facts["commands"][1]["parameters"][0]
output_fact = facts["outputs"]["files"][1]
candidate = {
"facts_sha256": facts["facts_sha256"],
"function_summary": "A valid model-provided summary.",
"inputs": [
{
"fact_id": facts["commands"][0]["inputs"][0]["fact_id"],
"role": "model-provided input role",
"description": "Model-provided input description.",
"replaceability": "fixed_resource",
}
],
"parameters": [
{
"fact_id": facts["commands"][0]["parameters"][0]["fact_id"],
"display_name": "Model-provided parameter name",
"description": "Model-provided parameter description.",
}
],
"outputs": [
{
"fact_id": facts["outputs"]["files"][0]["fact_id"],
"description": "Model-provided output description.",
"result_role": "final",
}
],
}
provided_rows = {
section: deepcopy(candidate[section])
for section in ("inputs", "parameters", "outputs")
}
original_candidate = deepcopy(candidate)
bound, audit = model.bind_fact_derived_semantics(facts, candidate)
assert candidate == original_candidate
assert bound["inputs"] == provided_rows["inputs"] + [
{
"fact_id": input_fact["fact_id"],
"role": f"Locked input: {input_fact['path']}.",
"description": f"Observed input file: {input_fact['path']}.",
"replaceability": "generated",
},
]
assert bound["parameters"] == provided_rows["parameters"] + [
{
"fact_id": parameter_fact["fact_id"],
"display_name": f"Locked parameter: {parameter_fact['value']}.",
"description": (
f"Observed command parameter: {parameter_fact['value']}."
),
}
]
assert bound["outputs"] == provided_rows["outputs"] + [
{
"fact_id": output_fact["fact_id"],
"description": f"Observed output file: {output_fact['path']}.",
"result_role": "unknown",
}
]
assert audit == [
{
"action": "complete_missing_semantic_row",
"section": "inputs",
"fact_id": input_fact["fact_id"],
},
{
"action": "complete_missing_semantic_row",
"section": "parameters",
"fact_id": parameter_fact["fact_id"],
},
{
"action": "complete_missing_semantic_row",
"section": "outputs",
"fact_id": output_fact["fact_id"],
},
]
assert model.validate_semantic_candidate(facts, bound) == []
@@ -0,0 +1,477 @@
from __future__ import annotations
from copy import deepcopy
import importlib
import json
from pathlib import Path
from types import SimpleNamespace
from gfa_core.extension_stage_lightweight.converter import build_execution_facts
from gfa_core.extension_stage_lightweight.evidence import run_raw_baseline
from gfa_core.extension_stage_lightweight.semantic_model import (
validate_semantic_candidate,
)
def _module():
return importlib.import_module(
"gfa_core.extension_stage_lightweight.semantic_repair"
)
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _facts(tmp_path: Path) -> dict:
raw_code = tmp_path / "raw_code"
raw_data = tmp_path / "raw_test_data"
_write(raw_code, "run.sh", "cp model.hmm result.txt\n")
_write(raw_data, "model.hmm", "HMMER3/f\n")
evidence = run_raw_baseline(
command="bash run.sh",
raw_code=raw_code,
raw_data=raw_data,
run_dir=tmp_path / "baseline",
)
return build_execution_facts(evidence)
def _candidate(facts: dict) -> dict:
command = facts["commands"][0]
return {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Copy an HMM model to an observed result file.",
"inputs": [
{
"fact_id": command["inputs"][0]["fact_id"],
"role": "hmm_model",
"description": "Uploaded HMM model.",
"replaceability": "replaceable",
}
],
"parameters": [
{
"fact_id": command["parameters"][0]["fact_id"],
"display_name": "result file",
"description": "Output filename from the original command.",
}
],
"outputs": [
{
"fact_id": facts["outputs"]["files"][0]["fact_id"],
"description": "Observed copied result.",
"result_role": "final",
}
],
}
def _patch(facts: dict, *, section: str, fact_id: str, fields: dict) -> dict:
return {
"facts_sha256": facts["facts_sha256"],
"changes": [
{
"section": section,
"fact_id": fact_id,
"fields": fields,
}
],
}
def test_automatic_repair_binds_generated_input_from_locked_facts() -> None:
module = _module()
facts = {
"facts_sha256": "a" * 64,
"commands": [
{
"inputs": [
{"fact_id": "step_001:input:001", "source": "baseline_input"}
],
"parameters": [],
},
{
"inputs": [
{"fact_id": "step_002:input:001", "source": "generated_input"}
],
"parameters": [],
},
],
"outputs": {
"files": [{"fact_id": "output:file:001"}],
"stdout": {"present": False},
},
}
candidate = {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Process an uploaded sequence through two steps.",
"inputs": [
{
"fact_id": "step_001:input:001",
"role": "uploaded sequence",
"description": "User-provided sequence file.",
"replaceability": "replaceable",
}
],
"parameters": [],
"outputs": [
{
"fact_id": "output:file:001",
"description": "Final processed sequence.",
"result_role": "final",
}
],
}
def generator(context: dict, attempt: int):
return (
_patch(
facts,
section="inputs",
fact_id="step_002:input:001",
fields={
"role": "intermediate sequence",
"description": "Sequence generated by the previous step.",
"replaceability": "replaceable",
},
),
{"request_id": "repair-generated"},
)
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
validation_errors=validate_semantic_candidate(facts, candidate),
patch_generator=generator,
)
assert repaired["inputs"][1]["replaceability"] == "generated"
assert history[0]["status"] == "success"
assert history[0]["metadata"]["fact_derived_semantic_bindings"][0][
"action"
] == "bind_generated_input"
def test_automatic_repair_changes_only_the_failed_field(tmp_path: Path) -> None:
module = _module()
facts = _facts(tmp_path)
candidate = _candidate(facts)
parameter_id = candidate["parameters"][0]["fact_id"]
candidate["parameters"][0].pop("description")
before = deepcopy(candidate)
errors = validate_semantic_candidate(facts, candidate)
seen_contexts: list[dict] = []
def generator(context: dict, attempt: int):
seen_contexts.append(context)
return (
_patch(
facts,
section="parameters",
fact_id=parameter_id,
fields={"description": "Output filename from the original command."},
),
{"request_id": "repair-1"},
)
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
validation_errors=errors,
patch_generator=generator,
)
assert candidate == before
assert repaired["parameters"][0]["description"].startswith("Output filename")
unchanged = deepcopy(repaired)
unchanged["parameters"][0].pop("description")
assert unchanged == before
assert seen_contexts[0]["allowed_targets"] == [
{
"section": "parameters",
"fact_id": parameter_id,
"field": "description",
}
]
assert history == [
{
"attempt": 1,
"mode": "automatic",
"status": "success",
"diff": [
{
"section": "parameters",
"fact_id": parameter_id,
"field": "description",
"before": None,
"after": "Output filename from the original command.",
}
],
"metadata": {"request_id": "repair-1"},
"errors": [],
}
]
def test_user_feedback_repairs_semantics_and_records_exact_diff(
tmp_path: Path,
) -> None:
module = _module()
facts = _facts(tmp_path)
original_facts = deepcopy(facts)
candidate = _candidate(facts)
original_candidate = deepcopy(candidate)
input_id = candidate["inputs"][0]["fact_id"]
def generator(context: dict, attempt: int):
assert context["user_feedback"] == "HMM 文件是固定资源,不是每次替换的输入"
return (
_patch(
facts,
section="inputs",
fact_id=input_id,
fields={
"description": "Bundled HMM model used by this analysis.",
"replaceability": "fixed_resource",
},
),
{},
)
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
user_feedback="HMM 文件是固定资源,不是每次替换的输入",
patch_generator=generator,
)
assert facts == original_facts
assert candidate == original_candidate
assert repaired["inputs"][0]["replaceability"] == "fixed_resource"
assert repaired["parameters"] == original_candidate["parameters"]
assert repaired["outputs"] == original_candidate["outputs"]
assert {item["field"] for item in history[0]["diff"]} == {
"description",
"replaceability",
}
assert history[0]["mode"] == "user_feedback"
def test_automatic_repair_rejects_unrelated_semantic_changes(
tmp_path: Path,
) -> None:
module = _module()
facts = _facts(tmp_path)
candidate = _candidate(facts)
parameter_id = candidate["parameters"][0]["fact_id"]
input_id = candidate["inputs"][0]["fact_id"]
candidate["parameters"][0].pop("description")
original = deepcopy(candidate)
def generator(context: dict, attempt: int):
return (
{
"facts_sha256": facts["facts_sha256"],
"changes": [
{
"section": "parameters",
"fact_id": parameter_id,
"fields": {"description": "Correct repair."},
},
{
"section": "inputs",
"fact_id": input_id,
"fields": {"description": "Unrequested rewrite."},
},
],
},
{},
)
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
validation_errors=validate_semantic_candidate(facts, candidate),
patch_generator=generator,
max_attempts=1,
)
assert repaired == original
assert history[-1]["status"] == "needs_user_confirmation"
assert any("not allowed" in error["message"] for error in history[-1]["errors"])
def test_repair_rejects_fact_rewrites_and_unknown_ids(tmp_path: Path) -> None:
module = _module()
facts = _facts(tmp_path)
candidate = _candidate(facts)
original = deepcopy(candidate)
invalid_patches = [
{
"facts_sha256": facts["facts_sha256"],
"original_command": "invented command",
"changes": [],
},
_patch(
facts,
section="inputs",
fact_id="step_999:input:999",
fields={"description": "Invented input."},
),
_patch(
facts,
section="inputs",
fact_id=candidate["inputs"][0]["fact_id"],
fields={"path": "invented.fa", "flag": "--invented"},
),
]
for invalid in invalid_patches:
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
user_feedback="revise this",
patch_generator=lambda context, attempt, patch=invalid: (patch, {}),
max_attempts=1,
)
assert repaired == original
assert history[-1]["status"] == "needs_user_confirmation"
assert history[-1]["errors"]
def test_repair_stops_after_twenty_invalid_attempts(tmp_path: Path) -> None:
module = _module()
facts = _facts(tmp_path)
candidate = _candidate(facts)
calls: list[int] = []
def generator(context: dict, attempt: int):
calls.append(attempt)
return ({"facts_sha256": "wrong", "changes": []}, {})
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
user_feedback="revise this",
patch_generator=generator,
max_attempts=100,
)
assert repaired == candidate
assert calls == list(range(1, 21))
assert len(history) == 20
assert history[-1]["status"] == "needs_user_confirmation"
assert history[-1]["errors"]
def test_default_deepseek_generator_requests_only_an_allowed_patch(
tmp_path: Path,
) -> None:
module = _module()
facts = _facts(tmp_path)
candidate = _candidate(facts)
parameter_id = candidate["parameters"][0]["fact_id"]
candidate["parameters"][0].pop("description")
patch = _patch(
facts,
section="parameters",
fact_id=parameter_id,
fields={"description": "Output filename from the original command."},
)
api_patch = deepcopy(patch)
api_patch.pop("facts_sha256")
requests: list[dict] = []
class FakeCompletions:
def create(self, **kwargs):
requests.append(kwargs)
return SimpleNamespace(
id="repair-request-1",
choices=[
SimpleNamespace(
message=SimpleNamespace(content=json.dumps(api_patch))
)
],
usage=SimpleNamespace(
prompt_tokens=30,
completion_tokens=20,
total_tokens=50,
),
)
fake_client = SimpleNamespace(
chat=SimpleNamespace(completions=FakeCompletions())
)
env_file = _write(
tmp_path,
".env",
"NEW_API_KEY=secret-test-key\n"
"NEW_API_BASE_URL=https://api.example.test/v1\n"
"NEW_API_MODEL=deepseek-test\n",
)
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
validation_errors=validate_semantic_candidate(facts, candidate),
raw_code=tmp_path / "raw_code",
environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
client_factory=lambda config: fake_client,
)
assert repaired["parameters"][0]["description"].startswith("Output filename")
request = requests[0]
assert request["temperature"] == 0
assert request["max_tokens"] == 6000
assert "Return only a semantic patch" in request["messages"][0]["content"]
payload = json.loads(request["messages"][1]["content"])
assert "facts_sha256" not in payload["required_patch_shape"]
assert payload["allowed_targets"] == [
{
"section": "parameters",
"fact_id": parameter_id,
"field": "description",
}
]
assert payload["execution_facts"] == facts
assert payload["candidate"] == candidate
assert "secret-test-key" not in json.dumps(request)
assert history[0]["metadata"]["request_id"] == "repair-request-1"
def test_deepseek_repair_error_keeps_auditable_raw_response(tmp_path: Path) -> None:
module = _module()
facts = _facts(tmp_path)
candidate = _candidate(facts)
candidate["parameters"][0].pop("description")
class FakeCompletions:
def create(self, **kwargs):
return SimpleNamespace(
id="invalid-repair-1",
choices=[SimpleNamespace(message=SimpleNamespace(content="[]"))],
usage=None,
)
env_file = _write(tmp_path, ".env", "NEW_API_KEY=secret-test-key\n")
repaired, history = module.repair_semantic_candidate(
execution_facts=facts,
candidate=candidate,
raw_code=tmp_path / "raw_code",
environ={"BBAV5_PROJECT_ENV_FILE": str(env_file)},
client_factory=lambda config: SimpleNamespace(
chat=SimpleNamespace(completions=FakeCompletions())
),
max_attempts=1,
)
assert repaired == candidate
assert history[0]["status"] == "needs_user_confirmation"
assert history[0]["metadata"]["request_id"] == "invalid-repair-1"
assert history[0]["metadata"]["raw_response"] == "[]"
assert history[0]["errors"][0]["code"] == "invalid_json"
@@ -0,0 +1,576 @@
from __future__ import annotations
from pathlib import Path
import threading
import time
import pytest
def _write(root: Path, relative: str, content: str) -> Path:
path = root / relative
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
return path
def _semantic_from_facts(facts: dict) -> dict:
return {
"facts_sha256": facts["facts_sha256"],
"function_summary": "Count FASTA records or copy one FASTA file.",
"inputs": [
{
"fact_id": item["fact_id"],
"role": "analysis_input",
"description": "Input supplied to the original command.",
"replaceability": "generated"
if item["source"] == "generated_input"
else "replaceable",
}
for command in facts["commands"]
for item in command["inputs"]
],
"parameters": [
{
"fact_id": item["fact_id"],
"display_name": item["flag"] or "command value",
"description": "Original command parameter.",
}
for command in facts["commands"]
for item in command["parameters"]
],
"outputs": [
{
"fact_id": item["fact_id"],
"description": "Observed analysis result.",
"result_role": "final",
}
for item in facts["outputs"]["files"]
]
+ (
[
{
"fact_id": facts["outputs"]["stdout"]["fact_id"],
"description": "Direct textual result.",
"result_role": "final",
}
]
if facts["outputs"]["stdout"].get("present")
else []
),
}
def _service(tmp_path: Path):
from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
return LightweightExtensionService(
tmp_path / "store",
semantic_generator=lambda facts, _raw_code: (_semantic_from_facts(facts), {}),
)
def _package(tmp_path: Path, *, stdout_only: bool) -> tuple[Path, Path, str]:
raw_code = tmp_path / "source" / "raw_code"
raw_data = tmp_path / "source" / "raw_test_data"
_write(raw_data, "input.fa", ">a\nAAAA\n>b\nBBBB\n")
if stdout_only:
_write(raw_code, "count.sh", "grep -c '^>' input.fa\n")
return raw_code, raw_data, "bash count.sh"
_write(raw_code, "copy.sh", "cp input.fa result.fa\n")
return raw_code, raw_data, "bash copy.sh"
def _published_copy_service(tmp_path: Path):
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=False)
service.upload(
extension_id="copy_fasta",
raw_code=raw_code,
raw_data=raw_data,
original_command=command,
operation_token="upload-1",
)
service.confirm("copy_fasta", operation_token="confirm-1")
service.validate("copy_fasta", operation_token="validate-1")
service.review_result("copy_fasta", operation_token="review-1")
service.publish("copy_fasta", operation_token="publish-1")
return service
def test_stdout_lifecycle_records_reviewed_validation_and_publishes(tmp_path: Path) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
created = service.upload(
extension_id="count_fasta",
raw_code=raw_code,
raw_data=raw_data,
original_command=command,
operation_token="upload-1",
)
assert created["status"] == "confirmation_pending"
assert service.get("count_fasta")["execution_facts"]["original_command"] == command
service.confirm("count_fasta", operation_token="confirm-1")
validation = service.validate("count_fasta", operation_token="validate-1")
state = service.get("count_fasta")
assert validation["status"] == "result_review_pending"
assert state["validation_runs"][0]["contract_passed"] is True
assert state["validation_runs"][0]["projection"]["stdout"]["present"] is True
service.review_result("count_fasta", operation_token="review-1")
published = service.publish("count_fasta", operation_token="publish-1")
assert published["status"] == "published"
assert service.get("count_fasta")["published_validation_run_id"] == validation["run_id"]
assert service.help("count_fasta")["original_command"] == command
def test_published_run_requires_job_context_and_projects_into_job_workspace(
tmp_path: Path,
) -> None:
service = _published_copy_service(tmp_path)
input_root = tmp_path / "input"
workspace_root = tmp_path / "workspace"
runtime_input = _write(input_root, "job_1/different_name.fa", ">x\nTTTT\n")
_write(workspace_root, "job_1/.keep", "")
with pytest.raises(ValueError, match="run_job_id"):
service.run(
"copy_fasta",
input_overrides={"input.fa": runtime_input},
operation_token="run-missing-context",
)
with pytest.raises(ValueError, match="selected job"):
service.run(
"copy_fasta",
input_overrides={"input.fa": _write(tmp_path, "outside.fa", ">z\n")},
run_job_id="job_1",
input_root=input_root,
workspace_root=workspace_root,
operation_token="run-outside",
)
result = service.run(
"copy_fasta",
input_overrides={"input.fa": runtime_input},
run_job_id="job_1",
input_root=input_root,
workspace_root=workspace_root,
operation_token="run-1",
)
runtime = service.get("copy_fasta")["runtime_runs"][-1]
output = Path(runtime["projection"]["files"][0]["absolute_path"])
assert runtime["success"] is True
assert output.read_text(encoding="utf-8") == ">x\nTTTT\n"
assert output.is_relative_to(
workspace_root / "job_1" / "extension_runs" / "copy_fasta"
)
def test_published_run_rejects_symlink_escape_from_selected_job(tmp_path: Path) -> None:
service = _published_copy_service(tmp_path)
input_root = tmp_path / "input"
workspace_root = tmp_path / "workspace"
outside = _write(input_root, "job_2/secret.fa", ">secret\n")
link = input_root / "job_1/link.fa"
link.parent.mkdir(parents=True, exist_ok=True)
link.symlink_to(outside)
_write(workspace_root, "job_1/.keep", "")
with pytest.raises(ValueError, match="selected job"):
service.run(
"copy_fasta",
input_overrides={"input.fa": link},
run_job_id="job_1",
input_root=input_root,
workspace_root=workspace_root,
operation_token="run-symlink-escape",
)
def test_fact_dispute_requires_rebuild_and_duplicate_token_uses_one_run(
tmp_path: Path,
) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(
extension_id="count_fasta",
raw_code=raw_code,
raw_data=raw_data,
original_command=command,
operation_token="upload-1",
)
disputed = service.revise(
"count_fasta",
feedback="The original input path is wrong.",
revision_target="execution_facts.path",
operation_token="revise-1",
)
assert disputed["status"] == "rebuild_required"
with pytest.raises(ValueError, match="rebuild"):
service.confirm("count_fasta", operation_token="confirm-1")
service.rebuild("count_fasta", operation_token="rebuild-1")
service.confirm("count_fasta", operation_token="confirm-2")
first = service.validate("count_fasta", operation_token="validate-once")
second = service.validate("count_fasta", operation_token="validate-once")
assert first == second
assert len(service.get("count_fasta")["validation_runs"]) == 1
def test_concurrent_same_token_starts_validation_once(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(
extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
original_command=command, operation_token="upload-1",
)
service.confirm("count_fasta", operation_token="confirm-1")
real_execute = service._execute
entered = threading.Event()
release = threading.Event()
calls = 0
def slow_execute(*args, **kwargs):
nonlocal calls
calls += 1
entered.set()
assert release.wait(timeout=3)
return real_execute(*args, **kwargs)
monkeypatch.setattr(service, "_execute", slow_execute)
results: list[dict] = []
first = threading.Thread(
target=lambda: results.append(service.validate("count_fasta", operation_token="once"))
)
first.start()
assert entered.wait(timeout=3)
second = threading.Thread(
target=lambda: results.append(service.validate("count_fasta", operation_token="once"))
)
second.start()
second.join(timeout=3)
release.set()
first.join(timeout=3)
assert calls == 1
assert {result["run_id"] for result in results} == {results[0]["run_id"]}
def test_stop_can_use_persisted_process_without_waiting_for_long_baseline(
tmp_path: Path,
) -> None:
service = _service(tmp_path)
raw_code = tmp_path / "source" / "raw_code"
raw_data = tmp_path / "source" / "raw_test_data"
raw_code.mkdir(parents=True)
raw_data.mkdir(parents=True)
_write(raw_code, "sleep.sh", "sleep 10\nprintf 'late result\\n'\n")
outcome: list[dict] = []
thread = threading.Thread(
target=lambda: outcome.append(
service.upload(
extension_id="slow", raw_code=raw_code, raw_data=raw_data,
original_command="bash sleep.sh", operation_token="upload-1",
)
)
)
thread.start()
deadline = time.monotonic() + 3
while time.monotonic() < deadline:
try:
state = service.get("slow")
except ValueError:
time.sleep(0.02)
continue
if state.get("active_process"):
break
time.sleep(0.02)
assert service.get("slow")["active_process"]["operation_token"] == "upload-1"
stopped = service.stop("slow", operation_token="upload-1")
thread.join(timeout=5)
assert stopped["stopped"] is True
assert not service.get("slow").get("active_process")
assert outcome[0]["status"] == "baseline_failed"
def test_initial_semantic_failure_is_persisted_and_rebuildable(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
failing = LightweightExtensionService(
tmp_path / "store",
semantic_generator=lambda _facts, _raw_code: (_ for _ in ()).throw(RuntimeError("semantic down")),
)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
first = failing.upload(
extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
original_command=command, operation_token="upload-1",
)
assert first["status"] == "build_failed"
assert failing.get("count_fasta")["error"]["phase"] == "semantic_candidate"
failing.semantic_generator = lambda facts, _raw_code: (_semantic_from_facts(facts), {})
rebuilt = failing.rebuild("count_fasta", operation_token="rebuild-1")
assert rebuilt["status"] == "confirmation_pending"
def test_semantic_retry_reuses_baseline_and_locked_execution_facts(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
calls = 0
def semantic_generator(facts: dict, _raw_code: Path):
nonlocal calls
calls += 1
if calls == 1:
raise RuntimeError("truncated semantic JSON")
return _semantic_from_facts(facts), {"attempt": calls}
service = LightweightExtensionService(
tmp_path / "store", semantic_generator=semantic_generator
)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
first = service.upload(
extension_id="count_retry",
raw_code=raw_code,
raw_data=raw_data,
original_command=command,
operation_token="upload-1",
)
failed = service.get("count_retry")
baseline_run_dir = failed["baseline"]["run_dir"]
facts_sha256 = failed["execution_facts"]["facts_sha256"]
retried = service.retry_semantic("count_retry", operation_token="semantic-2")
record = service.get("count_retry")
assert first["status"] == "build_failed"
assert retried["status"] == "confirmation_pending"
assert calls == 2
assert record["baseline"]["run_dir"] == baseline_run_dir
assert record["execution_facts"]["facts_sha256"] == facts_sha256
assert record["semantic_metadata"] == {"attempt": 2}
def test_semantic_retry_recovers_facts_from_saved_legacy_baseline(tmp_path: Path) -> None:
from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
service = LightweightExtensionService(
tmp_path / "store",
semantic_generator=lambda _facts, _raw_code: (_ for _ in ()).throw(
RuntimeError("semantic down")
),
)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(
extension_id="legacy_failure",
raw_code=raw_code,
raw_data=raw_data,
original_command=command,
operation_token="upload-1",
)
legacy = service.get("legacy_failure")
baseline_run_dir = legacy["baseline"]["run_dir"]
legacy.pop("execution_facts")
service._write_record("legacy_failure", legacy)
service.semantic_generator = lambda facts, _raw_code: (
_semantic_from_facts(facts),
{},
)
retried = service.retry_semantic(
"legacy_failure", operation_token="semantic-legacy"
)
record = service.get("legacy_failure")
assert retried["status"] == "confirmation_pending"
assert record["baseline"]["run_dir"] == baseline_run_dir
assert record["execution_facts"]["facts_sha256"]
def test_semantic_retry_is_noop_after_candidate_already_succeeded(
tmp_path: Path,
) -> None:
calls = 0
def semantic_generator(facts: dict, _raw_code: Path):
nonlocal calls
calls += 1
return _semantic_from_facts(facts), {}
from gfa_core.extension_stage_lightweight.service import LightweightExtensionService
service = LightweightExtensionService(
tmp_path / "store", semantic_generator=semantic_generator
)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(
extension_id="already_ready",
raw_code=raw_code,
raw_data=raw_data,
original_command=command,
operation_token="upload-1",
)
retried = service.retry_semantic(
"already_ready", operation_token="semantic-noop"
)
assert retried["status"] == "confirmation_pending"
assert calls == 1
assert service.get("already_ready")["status"] == "confirmation_pending"
def test_operation_records_are_small_references_not_record_snapshots(tmp_path: Path) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(
extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
original_command=command, operation_token="upload-1",
)
operation = service.get("count_fasta")["operations"]["upload-1"]
assert set(operation) <= {"action", "state", "response", "run_id"}
assert "baseline" not in operation["response"]
def test_unresolved_binding_finishes_validation_without_leaving_running(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")
service.confirm("count_fasta", operation_token="confirm-1")
monkeypatch.setattr(
service,
"_execute",
lambda *_args, run_id, **_kwargs: {
"run_id": run_id,
"success": False,
"contract_passed": False,
"binding": {"status": "blocked"},
"projection": {"files": [], "stdout": {"present": False}},
},
)
response = service.validate("count_fasta", operation_token="validate-1")
assert response["status"] == "validation_failed"
state = service.get("count_fasta")
assert state["active_process"] is None
assert state["operations"]["validate-1"]["state"] == "completed"
def test_early_stop_is_recorded_before_identity_and_honoured_on_callback(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import gfa_core.extension_stage_lightweight.service as service_module
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")
service.confirm("count_fasta", operation_token="confirm-1")
entered = threading.Event()
release = threading.Event()
monkeypatch.setattr(service, "_execute", lambda *_args, run_id, **_kwargs: (entered.set(), release.wait(3), {"run_id": run_id, "success": False, "contract_passed": False, "projection": {"files": [], "stdout": {"present": False}}})[2])
stopped: list[dict] = []
monkeypatch.setattr(service_module, "stop_process_group", lambda identity, **_kwargs: stopped.append(identity) or True)
worker = threading.Thread(target=lambda: service.validate("count_fasta", operation_token="validate-1"))
worker.start()
assert entered.wait(timeout=3)
pending = service.stop("count_fasta", operation_token="validate-1")
assert pending["stopped"] is False
assert service.get("count_fasta")["operations"]["validate-1"]["stop_requested"] is True
identity = {"pid": 11, "pgid": 11, "pid_start_time": 1, "supervisor_token": "x"}
service._mark_process("count_fasta", token="validate-1", phase="validation", identity=identity)
release.set()
worker.join(timeout=3)
assert stopped == [identity]
assert service.get("count_fasta")["active_process"] is None
def test_run_rejects_unsafe_job_id_before_path_resolution(tmp_path: Path) -> None:
service = _published_copy_service(tmp_path)
input_root = tmp_path / "input"
workspace_root = tmp_path / "workspace"
_write(input_root, "job_1/input.fa", ">x\n")
_write(workspace_root, "job_1/.keep", "")
with pytest.raises(ValueError, match="run_job_id"):
service.run(
"copy_fasta", input_overrides={"input.fa": input_root / "job_1/input.fa"},
run_job_id="../job_1", input_root=input_root, workspace_root=workspace_root,
operation_token="run-1",
)
def test_validation_failure_can_retry_or_rebuild(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")
service.confirm("count_fasta", operation_token="confirm-1")
actual = service._execute
failed = False
def fail_once(*args, run_id, **kwargs):
nonlocal failed
if not failed:
failed = True
return {"run_id": run_id, "success": False, "contract_passed": False, "projection": {"files": [], "stdout": {"present": False}}}
return actual(*args, run_id=run_id, **kwargs)
monkeypatch.setattr(service, "_execute", fail_once)
assert service.validate("count_fasta", operation_token="validate-fail")["status"] == "validation_failed"
assert service.validate("count_fasta", operation_token="validate-retry")["status"] == "result_review_pending"
rebuilder = _service(tmp_path / "rebuild-case")
rebuild_code, rebuild_data, rebuild_command = _package(tmp_path / "rebuild-case", stdout_only=True)
rebuilder.upload(extension_id="count_fasta", raw_code=rebuild_code, raw_data=rebuild_data, original_command=rebuild_command, operation_token="upload-1")
rebuilder.confirm("count_fasta", operation_token="confirm-1")
monkeypatch.setattr(rebuilder, "_execute", lambda *_args, run_id, **_kwargs: {"run_id": run_id, "success": False, "contract_passed": False, "projection": {"files": [], "stdout": {"present": False}}})
assert rebuilder.validate("count_fasta", operation_token="validate-fail")["status"] == "validation_failed"
assert rebuilder.rebuild("count_fasta", operation_token="rebuild-1")["status"] == "confirmation_pending"
def test_concurrent_duplicate_upload_token_reuses_reserved_operation(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
actual = service._run_baseline_operation
entered = threading.Event()
release = threading.Event()
def slow_baseline(*args, **kwargs):
entered.set()
assert release.wait(timeout=3)
return actual(*args, **kwargs)
monkeypatch.setattr(service, "_run_baseline_operation", slow_baseline)
results: list[dict] = []
first = threading.Thread(target=lambda: results.append(service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")))
first.start()
assert entered.wait(timeout=3)
second = threading.Thread(target=lambda: results.append(service.upload(extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data, original_command=command, operation_token="upload-1")))
second.start()
second.join(timeout=3)
release.set()
first.join(timeout=3)
assert len(results) == 2
assert {item["run_id"] for item in results} == {results[0]["run_id"]}
@pytest.mark.parametrize("token", ["", " "])
def test_upload_rejects_blank_token_before_creating_extension_directory(
tmp_path: Path, token: str
) -> None:
service = _service(tmp_path)
raw_code, raw_data, command = _package(tmp_path, stdout_only=True)
with pytest.raises(ValueError, match="operation_token"):
service.upload(
extension_id="count_fasta", raw_code=raw_code, raw_data=raw_data,
original_command=command, operation_token=token,
)
assert not service.extension_dir("count_fasta").exists()
@@ -0,0 +1,57 @@
from pathlib import Path
from gfa_core.extension_stage_lightweight.command_model import build_command_model
def _state(*paths: str) -> dict[str, dict[str, object]]:
return {path: {"kind": "file", "size": 1, "sha256": path} for path in paths}
def test_exact_output_reference_wins_over_prefix_match(tmp_path: Path) -> None:
(tmp_path / "run.sh").write_text(
"""tool --out result.tbl input.dat
filter result.tbl > result.tbl.filtered
""",
encoding="utf-8",
)
model = build_command_model(
command="bash run.sh",
work_dir=tmp_path,
before_files=_state("run.sh", "input.dat"),
output_files=["result.tbl", "result.tbl.filtered"],
code_files=["run.sh"],
)
assert model["commands"][0]["outputs"] == ["result.tbl"]
assert model["commands"][1]["outputs"] == ["result.tbl.filtered"]
def test_shell_positional_argument_is_bound_through_simple_variable(tmp_path: Path) -> None:
(tmp_path / "count.sh").write_text(
"""#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 1 ]; then echo usage >&2; exit 1; fi
input=$1
awk '/^>/{n++} END{print n+0}' "$input"
""",
encoding="utf-8",
)
model = build_command_model(
command="bash count.sh Ath.genome.fasta",
work_dir=tmp_path,
before_files=_state("count.sh", "Ath.genome.fasta"),
output_files=[],
code_files=["count.sh"],
)
assert [command["tool"] for command in model["commands"]] == ["awk"]
assert model["commands"][0]["inputs"] == [
{
"path": "Ath.genome.fasta",
"source": "baseline_input",
"flag": None,
"token_index": 2,
}
]
@@ -0,0 +1,41 @@
from pathlib import Path
from gfa_core.extension_stage_lightweight.command_model import build_command_model
def test_shell_pipeline_is_modelled_as_separate_executables(tmp_path: Path) -> None:
(tmp_path / "run.sh").write_text(
"""hmmsearch --cut_tc --domtblout hits.tbl model.hmm proteins.fa
awk '$7<0.01 {print $0}' hits.tbl > filtered.tbl
awk '{print $1}' filtered.tbl | sort -u > ids.txt
""",
encoding="utf-8",
)
before = {
name: {"kind": "file", "size": 1, "sha256": name}
for name in ("run.sh", "model.hmm", "proteins.fa")
}
model = build_command_model(
command="bash run.sh",
work_dir=tmp_path,
before_files=before,
output_files=["hits.tbl", "filtered.tbl", "ids.txt"],
code_files=["run.sh"],
)
assert [command["tool"] for command in model["commands"]] == [
"hmmsearch",
"awk",
"awk",
"sort",
]
assert model["commands"][1]["inputs"][0]["path"] == "hits.tbl"
assert model["commands"][2]["inputs"][0]["path"] == "filtered.tbl"
assert model["commands"][3]["parameters"][0] == {
"kind": "switch",
"flag": "-u",
"value": "-u",
"token_index": 1,
}
assert model["commands"][3]["outputs"] == ["ids.txt"]
@@ -0,0 +1,25 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
WEB = ROOT / "start_plantai_9000.sh"
WORKER = ROOT / "start_celery_9000.sh"
def test_target_launchers_share_lightweight_extension_runtime_roots() -> None:
web = WEB.read_text(encoding="utf-8")
worker = WORKER.read_text(encoding="utf-8")
for name in (
"BBAV5_PROJECT_ENV_FILE",
"BBAV5_LIGHTWEIGHT_EXTENSION_STORE",
"BBAV5_LIGHTWEIGHT_INPUT",
"BBAV5_LIGHTWEIGHT_WORKSPACE",
):
assert name in web
assert name in worker
assert 'export BBAV5_QUEUE_PREFIX="plantai_dev_9000"' in web
assert 'export BBAV5_REDIS_URL="redis://localhost:6379/10"' in web
assert 'BBAV5_QUEUE_PREFIX="plantai_dev_9000"' in worker
assert 'BBAV5_REDIS_URL="redis://localhost:6379/10"' in worker
@@ -0,0 +1,13 @@
from pathlib import Path
INDEX_HTML = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "index.html"
def test_cpu_monitor_polls_lightweight_resource_endpoint_independently():
source = INDEX_HTML.read_text(encoding="utf-8")
assert "const refreshResourceUsage = async () =>" in source
assert "fetch('/api/resource/status', { cache: 'no-store' })" in source
assert "resourceMonitorTimer = setInterval(refreshResourceUsage, 1000)" in source
assert "clearInterval(resourceMonitorTimer)" in source
@@ -0,0 +1,40 @@
from pathlib import Path
INDEX_HTML = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "index.html"
MAIN_PY = Path(__file__).resolve().parents[2] / "apps" / "web" / "vue3" / "main.py"
def test_execution_indicator_recovers_current_job_tasks_from_backend_runtime():
source = INDEX_HTML.read_text(encoding="utf-8")
assert "resource_tasks: []" in source
assert "runtimeOverview.resource_tasks = Array.isArray(data.resource_tasks) ? data.resource_tasks : [];" in source
assert "const currentJobRuntimeTasks = computed" in source
assert "String(task.job_id || '') === currentJobId" in source
assert "const executionIndicatorTask = computed" in source
def test_execution_indicator_visibility_and_stop_use_recovered_backend_task():
source = INDEX_HTML.read_text(encoding="utf-8")
assert 'v-if="showExecutionIndicator"' in source
assert "const showExecutionIndicator = computed" in source
assert "executionIndicatorTask.value" in source
assert "currentJobRuntimeTasks.value.forEach" in source
assert "task.task_id" in source
def test_execution_indicator_clears_only_after_backend_task_is_terminal():
source = INDEX_HTML.read_text(encoding="utf-8")
assert "const ACTIVE_RUNTIME_TASK_STATUSES" in source
assert "'queued', 'pending', 'running', 'started', 'waiting_resource', 'retry'" in source
assert "ACTIVE_RUNTIME_TASK_STATUSES.has" in source
def test_runtime_overview_does_not_truncate_active_resource_tasks():
source = MAIN_PY.read_text(encoding="utf-8")
assert 'overview["resource_tasks"] = resource.get("tasks", [])' in source
assert 'resource.get("tasks", [])[:20]' not in source
@@ -0,0 +1,11 @@
from gfa_core.gene_family_scope import reference_search_roots
def test_reference_search_roots_reads_configured_root(monkeypatch, tmp_path) -> None:
reference_root = tmp_path / "references"
reference_root.mkdir()
monkeypatch.setenv("PLANTAI_REF_DATA_ROOT", str(reference_root))
roots = reference_search_roots(tmp_path / "input")
assert reference_root.resolve() in roots
@@ -0,0 +1,53 @@
from pathlib import Path
from gfa_core.mas.agent_state_repository import SQLiteAgentStateRepository
from gfa_core.mas.persistent_conversation import PersistentConversationStore
def _store(tmp_path: Path) -> PersistentConversationStore:
repository = SQLiteAgentStateRepository(tmp_path / "agent-state.sqlite3")
repository.initialize()
return PersistentConversationStore(repository)
def test_rebind_to_different_job_creates_empty_conversation_and_preserves_old(tmp_path: Path) -> None:
store = _store(tmp_path)
old_turn = store.begin_turn(
session_id="old-session",
job_id="job_old",
query="旧 job 的问题",
client_history=[],
)
new_conversation_id = store.rebind_job(
session_id=old_turn.conversation_id,
job_id="job_new",
)
assert new_conversation_id != old_turn.conversation_id
old_conversation = store.repository.get_conversation(old_turn.conversation_id)
new_conversation = store.repository.get_conversation(new_conversation_id)
assert old_conversation is not None and old_conversation.job_id == "job_old"
assert new_conversation is not None and new_conversation.job_id == "job_new"
assert [message.content for message in store.repository.list_recent_messages(old_turn.conversation_id)] == ["旧 job 的问题"]
assert store.repository.list_recent_messages(new_conversation_id) == ()
ledger = store.repository.get_goal_ledger(new_conversation_id)
assert ledger is not None
assert ledger.user_terms == {"active_job_id": "job_new"}
def test_rebind_to_same_job_keeps_conversation_identity(tmp_path: Path) -> None:
store = _store(tmp_path)
turn = store.begin_turn(
session_id="same-session",
job_id="job_same",
query="当前 job 的问题",
client_history=[],
)
conversation_id = store.rebind_job(
session_id=turn.conversation_id,
job_id="job_same",
)
assert conversation_id == turn.conversation_id
@@ -0,0 +1,77 @@
import asyncio
import json
import os
from pathlib import Path
def test_result_browser_prefers_latest_manifest_and_shows_subfamily_evidence(monkeypatch, tmp_path):
from apps.web.vue3 import main as web_main
workspace_root = tmp_path / "workspace"
final_dir = workspace_root / "job-results" / "final"
final_dir.mkdir(parents=True)
image = final_dir / "Target.PEBP.target-Os.ref-At.subfamily_evidence.motif.png"
image.write_bytes(b"png")
success = final_dir / "Target.PEBP.target-Os.ref-At.subfamily_evidence.motif.manifest.json"
success.write_text(
json.dumps(
{
"stage": "motif",
"status": "success",
"prefix": "Os.PEBP",
"output_prefix": "Target.PEBP.target-Os.ref-At.subfamily_evidence",
"scope_id": "PEBP.target-Os.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"analysis_route": "subfamily_evidence",
"result_group": "motif_subfamily_evidence",
"elapsed_sec": 3.0,
"artifacts": {
"motif_plot_png": {
"artifact": "motif_plot",
"path": f"final/{image.name}",
"type": "image",
}
},
}
),
encoding="utf-8",
)
failed = final_dir / "Os.PEBP.target_only.motif.manifest.json"
failed.write_text(
json.dumps(
{
"stage": "motif",
"status": "failed",
"prefix": "Os.PEBP",
"scope_id": "PEBP.target-Os.target_only",
"analysis_route": "multi_species",
"parameters": {"error": "old failed attempt"},
"artifacts": {},
}
),
encoding="utf-8",
)
os.utime(failed, (1, 1))
os.utime(success, (2, 2))
monkeypatch.setattr(web_main, "WORKSPACE_ROOT", workspace_root)
result = asyncio.run(web_main.get_results(job_id="job-results", stage="motif"))
assert result["motif"]["status"] == "success"
assert image.name in json.dumps(result["motif"], ensure_ascii=False)
def test_result_panel_has_three_persistent_workflow_groups():
html = (Path(__file__).resolve().parents[2] / "apps/web/vue3/index.html").read_text(encoding="utf-8")
assert "RNA-seq" in html
assert "基因家族" in html
assert "WGS2GWAS(遗传变异与群体分析)" in html
assert "const resultGroups = computed" in html
assert "const groupStages = stages.filter(stage => resultGroupNameForStage(stage.name) === group.name);" in html
assert "wgsPopulationGwasResultRank(a.name) - wgsPopulationGwasResultRank(b.name)" in html
assert "name: 'wgs_qc'" in html
assert "name: 'wgs_maf_missing_filter'" in html
assert "await refreshResults(completedJobId, stageName);" in html
assert "await refreshResults(latestConversation.job_id);" in html
@@ -0,0 +1,62 @@
import asyncio
import json
import sys
import types
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from gfa_core.mas.tools.stage_tools import StageTool, StageToolExecutor # noqa: E402
def test_stage_tool_executor_passes_preflight_tree_to_backend(monkeypatch, tmp_path):
captured = {}
@dataclass
class FakeJobInfo:
job_id: str
input_dir: str
workspace_dir: str
logs_dir: str
created_at: str
def fake_run_stage(**kwargs):
captured.update(kwargs)
final_dir = Path(kwargs["job"].workspace_dir) / "final"
final_dir.mkdir(parents=True, exist_ok=True)
manifest = final_dir / "Target.PEBP.motif.manifest.json"
manifest.write_text(
json.dumps({"status": "success", "stage": "motif", "prefix": "Target.PEBP"}),
encoding="utf-8",
)
return "motif completed", manifest
fake_module = types.ModuleType("apps.web.pipeline_backend")
fake_module.JobInfo = FakeJobInfo
fake_module.run_stage = fake_run_stage
monkeypatch.setitem(sys.modules, "apps.web.pipeline_backend", fake_module)
selected_tree = tmp_path / "Target.PEBP.subfamily_evidence.tree.nwk"
selected_tree.write_text("(Os1,At1);\n", encoding="utf-8")
result = asyncio.run(
StageToolExecutor(project_root=tmp_path).execute(
stage_tool=StageTool(
id="motif",
name="Motif",
script="apps/cli/run_motif.py",
category="gene_family",
description="test",
),
job_id="job_tree_passthrough",
prefix="Target.PEBP",
extra_params={"treeFile": str(selected_tree), "cpu": 2},
)
)
assert result["status"] == "success"
assert captured["tree_file"] == str(selected_tree)
@@ -1,5 +1,13 @@
import importlib.util
import json
from pathlib import Path
from types import SimpleNamespace
from gfa_core.gene_family_stage_context import (
enrich_stage_params_with_context,
infer_analysis_route,
)
from gfa_core.mas.artifact_resolver import ArtifactResolver
def _load_vue3_main():
@@ -40,3 +48,285 @@ def test_subfamily_prediction_plan_uses_per_target_evidence_route():
assert detail_by_id["reference_classification"]["params"]["analysisRoute"] == "subfamily_prediction"
assert plan["total_stages"] == 7
assert intent["primary_stage"] == "reference_classification"
def test_subfamily_request_roles_do_not_promote_reference_to_target(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_configured_species_scope",
lambda _job_id: {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
},
)
monkeypatch.setattr(
main,
"_extract_species_codes_from_text_for_job",
lambda _job_id, _message: ["Os", "At"],
)
roles = main._resolve_subfamily_request_species_roles(
"job-real",
"执行目标物种 Os 和参考物种 At 的亚家族分类",
{
"target_species": ["Os"],
"reference_species": ["At"],
"params": {"targetSpecies": ["Os"], "referenceSpecies": ["At"]},
},
)
assert roles == {
"target_species": ["Os"],
"reference_species": ["At"],
"comparative_species": [],
"classification_species": ["Os"],
}
def test_generic_target_reference_scope_does_not_guess_subfamily_from_stage_name():
scope = {
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": [],
}
assert infer_analysis_route("phylogeny", scope) == "multi_species"
assert infer_analysis_route("motif", scope) == "multi_species"
def test_newick_manifest_artifact_satisfies_tree_requirement():
assert ArtifactResolver._kind_matches("tree", "newick") is True
def test_semantic_preflight_materializes_selected_tree(monkeypatch):
main = _load_vue3_main()
class Selected:
path = "/workspace/final/Target.PEBP.subfamily_evidence.tree.nwk"
class Decision:
resolved = True
selected = Selected()
candidates = []
def to_dict(self):
return {
"resolved": True,
"selected": {
"path": self.selected.path,
"kind": "newick",
"source": "artifact:phylogeny:tree",
},
"candidates": [],
}
class Resolver:
def __init__(self, _context):
pass
def resolve_stage(self, stage, species="", family=""):
return {
"family_members_pep": Decision(),
"tree": Decision(),
}
context = SimpleNamespace(
family="PEBP",
target_species=["Os"],
reference_species=["At"],
comparative_species=[],
config_path="/workspace/input/config.xlsx",
)
monkeypatch.setattr(main, "build_project_context", lambda **_kwargs: context)
monkeypatch.setattr(main, "ArtifactResolver", Resolver)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
result = main._api_semantic_preflight(
"job-direct-motif",
"motif",
"Os.PEBP",
{
"geneFamily": "PEBP",
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisRoute": "subfamily_evidence",
},
)
assert result["valid"] is True
assert result["materialized_params"]["treeFile"] == Selected.path
def test_scoped_tree_resolution_prefers_preflight_selected_tree(tmp_path):
from apps.web import pipeline_backend
workspace = tmp_path / "workspace"
final_dir = workspace / "final"
final_dir.mkdir(parents=True)
scope_file = final_dir / "Os.PEBP.scope.json"
scope_file.write_text(
json.dumps({
"scope_id": "PEBP.target-Os.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Os"],
"reference_species": ["At"],
}),
encoding="utf-8",
)
selected_tree = final_dir / "Target.PEBP.subfamily_evidence.tree.nwk"
selected_tree.write_text("(Os1,At1);\n", encoding="utf-8")
resolved, _scope = pipeline_backend.resolve_scoped_tree_from_phylogeny(
workspace,
final_dir,
"Os.PEBP",
scope_file,
explicit_tree_file=str(selected_tree),
)
assert resolved == selected_tree.resolve()
def test_explicit_subfamily_and_regular_routes_remain_separate():
shared = {
"geneFamily": "SPL",
"targetSpecies": ["Ft", "Os"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisMode": "joint_multi_species",
}
evidence = enrich_stage_params_with_context(
"motif",
"Target.SPL",
{**shared, "analysisRoute": "subfamily_evidence"},
)
prediction = enrich_stage_params_with_context(
"reference_classification",
"Target.SPL",
{**shared, "analysisRoute": "subfamily_prediction"},
)
regular = enrich_stage_params_with_context("motif", "Target.SPL", shared)
assert evidence["analysisRoute"] == "subfamily_evidence"
assert evidence["memberSetId"].endswith(":subfamily_evidence:domain_refined")
assert evidence["analysisMode"] == "joint_multi_species"
assert prediction["analysisRoute"] == "subfamily_prediction"
assert regular["analysisRoute"] == "multi_species"
assert regular["memberSetId"].endswith(":multi_species:all_members")
def test_direct_motif_request_uses_job_subfamily_state(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_load_identify_species_selection",
lambda _job_id: {
"groups": {
"target": [{"species": "Ft"}],
"reference": [{"species": "At"}],
"comparative": [],
}
},
)
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "SPL")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
monkeypatch.setattr(main, "_extract_species_codes_from_text_for_job", lambda _job_id, _text: [])
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda _job_id, _species, _family: {
"all_confirmed": False,
"by_species": {"Ft": {"status": "not_classified"}},
"issues": [{"code": "subfamily_classification_missing"}],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-direct-motif",
"执行 Motif 分析",
"Ft.SPL",
requested_stages=["motif"],
)
assert contextual["stages"] == ["motif"]
assert contextual["targetSpecies"] == ["Ft"]
assert contextual["referenceSpecies"] == ["At"]
assert contextual["contextGate"]["needs_subfamily_decision"] is True
def test_direct_stage_preserves_species_roles_from_intent_when_selector_is_empty(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "PEBP")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda _job_id, _family: [])
monkeypatch.setattr(main, "_extract_species_codes_from_text_for_job", lambda _job_id, _text: [])
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda _job_id, _species, _family: {
"all_confirmed": False,
"by_species": {"Os": {"status": "not_classified"}},
"issues": [],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-direct-motif",
"执行 Motif 分析",
"Os.PEBP",
requested_stages=["motif"],
request_intent={
"target_species": ["Os"],
"reference_species": ["At"],
"params": {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
},
},
)
assert contextual["targetSpecies"] == ["Os"]
assert contextual["referenceSpecies"] == ["At"]
assert contextual["scopeMode"] == "target_plus_reference"
request = SimpleNamespace(
job_id="job-direct-motif",
message="执行 Motif 分析",
prefix="Os.PEBP",
intent={
"stages": ["motif"],
"params": {
"targetSpecies": ["Os"],
"referenceSpecies": ["At"],
},
},
)
response = main._build_contextual_gene_family_plan_response(
request,
"session-direct-motif",
contextual,
)
details = {item["id"]: item for item in response["plan"]["all_stages_detail"]}
assert details["phylogeny"]["params"]["analysisRoute"] == "subfamily_evidence"
assert details["motif"]["params"]["analysisRoute"] == "subfamily_evidence"
assert details["phylogeny"]["params"]["memberSetId"] == details["motif"]["params"]["memberSetId"]