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:
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user