feat: release 2.3.0 with workspace plugin extensions
This commit is contained in:
1 parent
8380057548
commit
bb873ec0e0
44 files changed
+1497
-48
No files matched your search
@@ -0,0 +1,272 @@
|
||||
"""Workspace plugins use isolated persistence and never execute models or devices."""
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
import sys
|
||||
from uuid import uuid4
|
||||
import zipfile
|
||||
|
||||
from docx import Document
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from openpyxl import load_workbook
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.database.repositories.session_repository import SessionRepository
|
||||
from apps.admin_console.database.repositories.step_repository import StepRepository
|
||||
from apps.admin_console.routers import task_extensions as api
|
||||
from apps.admin_console.services import batch_archive, report_export, task_extensions
|
||||
from apps.admin_console.services.automation_store import AutomationStore
|
||||
from artemis.data_engine.storage import StorageManager
|
||||
from artemis.plugins.installer import install_archive
|
||||
from artemis.plugins.models import PluginManifest, PluginSelection
|
||||
from artemis.plugins.store import PluginStore
|
||||
|
||||
|
||||
def manifest_payload():
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"id": "review",
|
||||
"name": "人工评审",
|
||||
"version": "1.0.0",
|
||||
"platforms": [sys.platform],
|
||||
"targets": [],
|
||||
"capabilities": {"kind": "other"},
|
||||
"workspace": {
|
||||
"panels": [
|
||||
{"id": "prompt", "title": "任务需求", "side": "left", "kind": "initial_prompt"},
|
||||
{"id": "review", "title": "人工评审", "side": "right", "kind": "form"},
|
||||
],
|
||||
"fields": [
|
||||
{
|
||||
"id": "accuracy",
|
||||
"label": "AI执行准确性",
|
||||
"kind": "choice",
|
||||
"options": ["Pass", "Failed"],
|
||||
},
|
||||
{"id": "accuracy_note", "label": "执行备注", "kind": "text", "max_length": 100},
|
||||
{
|
||||
"id": "correctness",
|
||||
"label": "测试结果正确性",
|
||||
"kind": "choice",
|
||||
"options": ["Pass", "Failed"],
|
||||
},
|
||||
{"id": "correctness_note", "label": "结果备注", "kind": "text"},
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def install(plugins):
|
||||
package = BytesIO()
|
||||
with zipfile.ZipFile(package, "w") as archive:
|
||||
archive.writestr("plugin.json", json.dumps(manifest_payload()))
|
||||
install_archive(plugins, package.getvalue())
|
||||
plugins.set_enabled("review", True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def context(tmp_path, monkeypatch):
|
||||
path = tmp_path / "data.db"
|
||||
StorageManager(db_path=path, base_trace_dir=tmp_path / "traces")
|
||||
store = AutomationStore(path)
|
||||
plugins = PluginStore(tmp_path / "plugins")
|
||||
monkeypatch.setattr(task_extensions, "plugin_store", lambda: plugins)
|
||||
for module in (api, report_export):
|
||||
monkeypatch.setattr(module, "store", store)
|
||||
monkeypatch.setattr(report_export, "SessionRepository", lambda *args: SessionRepository(path))
|
||||
monkeypatch.setattr(report_export, "StepRepository", lambda *args: StepRepository(path))
|
||||
monkeypatch.setattr(report_export, "TRACES_PATH", tmp_path / "traces")
|
||||
monkeypatch.setattr(report_export, "EXPORT_ROOT", tmp_path / "exports")
|
||||
store.save_job(
|
||||
{
|
||||
"session_id": "task-one",
|
||||
"status": "completed",
|
||||
"case": {
|
||||
"name": "测试任务",
|
||||
"goal": "# 最初的需求\n\n|项目|要求|\n|---|---|\n|页面|可见|",
|
||||
},
|
||||
}
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(api.router)
|
||||
with TestClient(app) as client:
|
||||
yield store, plugins, client
|
||||
|
||||
|
||||
def save(client, values, revision=0, request_id=None):
|
||||
return client.put(
|
||||
"/api/task-extensions/task-one/review",
|
||||
json={
|
||||
"values": values,
|
||||
"expected_revision": revision,
|
||||
"request_id": request_id or str(uuid4()),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_workspace_requires_v3_and_valid_bounded_declarations():
|
||||
for change in (
|
||||
{"schema_version": 2},
|
||||
{
|
||||
"workspace": {
|
||||
"panels": [
|
||||
{
|
||||
"id": "prompt",
|
||||
"title": "需求",
|
||||
"side": "left",
|
||||
"kind": "initial_prompt",
|
||||
"width": {"minimum": 500, "preferred": 300},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"workspace": {
|
||||
"panels": [{"id": "prompt", "title": "需求", "side": "left", "kind": "javascript"}]
|
||||
}
|
||||
},
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
PluginManifest.model_validate({**manifest_payload(), **change})
|
||||
|
||||
|
||||
def test_plugin_is_optional_and_not_selected_for_execution(context):
|
||||
store, plugins, client = context
|
||||
assert client.get("/api/task-extensions/task-one").json()["plugins"] == []
|
||||
assert save(client, {}).status_code == 409
|
||||
install(plugins)
|
||||
assert plugins.selections() == []
|
||||
with pytest.raises(ValueError, match="工作区扩展"):
|
||||
plugins.pin([PluginSelection(id="review")], "android")
|
||||
data = client.get("/api/task-extensions/task-one").json()
|
||||
assert data["initial_prompt"].startswith("# 最初的需求")
|
||||
assert set(data["plugins"][0]["values"].values()) == {""}
|
||||
assert save(client, {}).status_code == 200
|
||||
assert store.get("case_review", "task-one") is None
|
||||
|
||||
|
||||
def test_prompt_is_initial_session_request_not_case_or_step_text(context):
|
||||
store, _, client = context
|
||||
with store.connection() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,initial_goal,status,start_time) VALUES (?,?,?,?)",
|
||||
("task-one", "原始任务需求", "completed", 1),
|
||||
)
|
||||
conn.commit()
|
||||
assert client.get("/api/task-extensions/task-one").json()["initial_prompt"] == "原始任务需求"
|
||||
|
||||
|
||||
def test_partial_clear_cas_and_idempotent_retry_preserve_history(context):
|
||||
store, plugins, client = context
|
||||
install(plugins)
|
||||
request_id = str(uuid4())
|
||||
assert (
|
||||
save(
|
||||
client, {"accuracy": "Pass", "accuracy_note": "人工核对"}, request_id=request_id
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert (
|
||||
save(
|
||||
client, {"accuracy": "Pass", "accuracy_note": "人工核对"}, request_id=request_id
|
||||
).status_code
|
||||
== 200
|
||||
)
|
||||
assert save(client, {"accuracy": "Failed"}, request_id=request_id).status_code == 409
|
||||
assert save(client, {"correctness": "Failed"}, revision=0).status_code == 409
|
||||
assert save(client, {"correctness": "Failed"}, revision=1).status_code == 200
|
||||
result = save(client, {"accuracy": "", "accuracy_note": ""}, revision=2).json()["plugins"][0]
|
||||
assert result["values"]["accuracy"] == ""
|
||||
assert result["values"]["correctness"] == "Failed"
|
||||
assert len(result["history"]) == 3
|
||||
assert result["history"][0]["values"]["accuracy"] == "Pass"
|
||||
assert store.get("case_review", "task-one") is None
|
||||
|
||||
|
||||
def test_invalid_running_missing_and_disabled_writes_are_rejected(context):
|
||||
store, plugins, client = context
|
||||
install(plugins)
|
||||
for values in ({"unknown": "Pass"}, {"accuracy": "Maybe"}, {"accuracy_note": "x" * 101}):
|
||||
assert save(client, values).status_code == 409
|
||||
store.save_job({**store.job("task-one"), "status": "running"})
|
||||
assert not client.get("/api/task-extensions/task-one").json()["editable"]
|
||||
assert save(client, {"accuracy": "Pass"}).status_code == 409
|
||||
assert client.get("/api/task-extensions/missing").status_code == 404
|
||||
assert store.get("task_extensions", "task-one") is None
|
||||
|
||||
|
||||
def test_disable_uninstall_reinstall_keep_history_and_gate_exports(context):
|
||||
store, plugins, client = context
|
||||
install(plugins)
|
||||
save(client, {"accuracy": "Pass"})
|
||||
plugins.set_enabled("review", False)
|
||||
assert task_extensions.report_sections("task-one", store) == []
|
||||
assert save(client, {"accuracy": "Failed"}, revision=1).status_code == 409
|
||||
plugins.uninstall("review")
|
||||
assert client.get("/api/task-extensions/task-one").json()["plugins"] == []
|
||||
install(plugins)
|
||||
data = client.get("/api/task-extensions/task-one").json()["plugins"][0]
|
||||
assert data["values"]["accuracy"] == "Pass"
|
||||
assert data["revision"] == 1
|
||||
store.save_job({"session_id": "rerun", "status": "completed", "case": {"goal": "同样需求"}})
|
||||
assert client.get("/api/task-extensions/rerun").json()["plugins"][0]["revision"] == 0
|
||||
store.delete_history("task-one")
|
||||
assert store.get("task_extensions", "task-one") is None
|
||||
|
||||
|
||||
def test_word_excel_blank_fields_and_cache_refresh(context):
|
||||
store, plugins, client = context
|
||||
install(plugins)
|
||||
original_result = report_export.session_report("task-one")["test_result"]
|
||||
paths = report_export.export_session("task-one", ["docx", "xlsx"])
|
||||
wb = load_workbook(paths[1])
|
||||
assert wb["人工评审"].cell(2, 3).value is None
|
||||
save(client, {"accuracy": "Pass", "accuracy_note": "=1+1"})
|
||||
paths = report_export.export_session("task-one", ["docx", "xlsx"])
|
||||
wb = load_workbook(paths[1])
|
||||
assert wb["人工评审"].cell(2, 3).value == "Pass"
|
||||
assert wb["人工评审"].cell(3, 3).data_type != "f"
|
||||
assert "Pass" in " ".join(
|
||||
cell.text for table in Document(paths[0]).tables for row in table.rows for cell in row.cells
|
||||
)
|
||||
assert report_export.session_report("task-one")["test_result"] == original_result
|
||||
plugins.set_enabled("review", False)
|
||||
paths = report_export.export_session("task-one", ["docx", "xlsx"])
|
||||
assert "人工评审" not in load_workbook(paths[1]).sheetnames
|
||||
assert not any("人工评审" in p.text for p in Document(paths[0]).paragraphs)
|
||||
|
||||
|
||||
def test_batch_snapshot_signature_changes_on_review_and_plugin_state(context):
|
||||
store, plugins, client = context
|
||||
install(plugins)
|
||||
store.create_batch(
|
||||
{
|
||||
"id": "batch",
|
||||
"name": "批次",
|
||||
"status": "completed",
|
||||
"formats": ["docx", "xlsx"],
|
||||
"report_ready": True,
|
||||
},
|
||||
[],
|
||||
{},
|
||||
)
|
||||
job = store.job("task-one")
|
||||
store.delete_history("task-one")
|
||||
store.save_job({**job, "batch_id": "batch"})
|
||||
path = report_export.export_batch("batch")
|
||||
assert batch_archive.cached("batch", store.get("batch", "batch"))
|
||||
with zipfile.ZipFile(path) as archive:
|
||||
assert "人工评审" in load_workbook(BytesIO(archive.read("批次结果.xlsx"))).sheetnames
|
||||
assert "人工评审" in " ".join(
|
||||
p.text for p in Document(BytesIO(archive.read("批次汇总.docx"))).paragraphs
|
||||
)
|
||||
save(client, {"correctness": "Failed"})
|
||||
# Even a late worker publishing report_ready cannot resurrect an old archive.
|
||||
assert not batch_archive.cached("batch", {"report_ready": True})
|
||||
report_export.export_batch("batch")
|
||||
plugins.set_enabled("review", False)
|
||||
assert not batch_archive.cached("batch", {"report_ready": True})
|
||||
store.begin_delete_batch("batch")
|
||||
store.delete_batch("batch")
|
||||
assert store.get("task_extensions", "task-one") is None
|
||||
@@ -19,6 +19,38 @@ from artemis.plugins.store import PluginStore
|
||||
from artemis.runtime.device_lock import DeviceBusyError, DeviceExecutionLock
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("configured", "expected"),
|
||||
[
|
||||
(
|
||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog.json",
|
||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
|
||||
),
|
||||
(
|
||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
|
||||
"https://git.playones.com/chenmingxuan/Artemis-Plugins/raw/branch/main/catalog-v3.json",
|
||||
),
|
||||
("https://example.test/catalog.json", "https://example.test/catalog.json"),
|
||||
],
|
||||
)
|
||||
def test_workspace_catalog_routes_only_the_official_legacy_address(
|
||||
monkeypatch, configured, expected
|
||||
):
|
||||
from artemis.plugins import installer
|
||||
|
||||
requested = []
|
||||
monkeypatch.setattr(installer.settings, "ARTEMIS_PLUGIN_MARKETPLACE_URL", configured)
|
||||
|
||||
def download(url, progress):
|
||||
requested.append(url)
|
||||
return b'{"schema_version":1,"plugins":[]}'
|
||||
|
||||
monkeypatch.setattr(installer, "download", download)
|
||||
assert installer.load_catalog().plugins == []
|
||||
assert requested == [expected]
|
||||
assert installer.settings.ARTEMIS_PLUGIN_MARKETPLACE_URL == configured
|
||||
|
||||
|
||||
def manifest(**updates):
|
||||
return PluginManifest.model_validate(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user