feat: prepare 2.2.0 with UI and execution fixes
Add dark theme, richer Markdown and readable plugin results. Improve Android input evidence, response integrity and diagnostics. Unify lifecycle, guidance receipts and evidence sources across clients. Keep direct action dispatch without an added AI quota approval layer. Align version metadata at 2.2.0 and bundle Helper 1.2.2. Validation: 3063 backend tests before the version bump; 44 after. Frontend: 317 tests and production build passed.
This commit is contained in:
1 parent
83bcfd54d5
commit
fac4ccb760
146 files changed
+5689
-2033
No files matched your search
@@ -1173,6 +1173,112 @@ def test_android_artifact_export_does_not_follow_other_session_links(isolated):
|
||||
assert reports.session_artifacts({"id": "session"}) == []
|
||||
|
||||
|
||||
def test_report_source_inventory_preserves_human_verdict_and_raw_evidence(
|
||||
isolated, tmp_path, monkeypatch
|
||||
):
|
||||
from apps.admin_console.services import evidence_service, task_inspection
|
||||
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
sid = job["session_id"]
|
||||
action = {
|
||||
"action": "plug_read",
|
||||
"plugin_id": "data-reader",
|
||||
"call_id": "recorded-call",
|
||||
"read_only": True,
|
||||
"source": {"kind": "api", "target_id": "business-service", "label": "安卓业务后台"},
|
||||
}
|
||||
with sqlite3.connect(isolated.path) as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO sessions(session_id,initial_goal,start_time,end_time,status) VALUES (?,?,?,?,?)",
|
||||
(sid, "检查业务状态", 1700000010, 1700000120, "completed"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO steps(step_id,session_id,step_number,action_taken) VALUES (?,?,?,?)",
|
||||
("actual-step", sid, 7, json.dumps(action)),
|
||||
)
|
||||
isolated.save_case_review(sid, "failed", "用户指出业务状态仍未落库")
|
||||
monkeypatch.setattr(evidence_service, "TRACES_PATH", reports.TRACES_PATH)
|
||||
report = reports.session_report(sid)
|
||||
source = report["evidence_sources"]["sources"][0]
|
||||
assert source["category"] == "auxiliary"
|
||||
assert source["references"][0]["step_id"] == "actual-step"
|
||||
assert report["test_result"]["status"] == "failed"
|
||||
assert report["test_result"]["source"] == "human"
|
||||
assert report["steps"][0]["action_taken"] == action
|
||||
index = evidence_service.export_index(sid, report, [])
|
||||
assert index["evidence_sources"] == report["evidence_sources"]
|
||||
monkeypatch.setattr(task_inspection, "store", isolated)
|
||||
monkeypatch.setattr(task_inspection, "session_repo", reports.SessionRepository())
|
||||
assert (
|
||||
task_inspection.inspect_task(sid, "report")["evidence_sources"]
|
||||
== report["evidence_sources"]
|
||||
)
|
||||
from docx import Document
|
||||
from openpyxl import load_workbook
|
||||
|
||||
word = tmp_path / "sources.docx"
|
||||
sheet = tmp_path / "sources.xlsx"
|
||||
reports.word_report(report, word)
|
||||
reports.excel_report(report, sheet)
|
||||
document = Document(word)
|
||||
word_text = " ".join(
|
||||
[p.text for p in document.paragraphs]
|
||||
+ [cell.text for table in document.tables for row in table.rows for cell in row.cells]
|
||||
)
|
||||
workbook = load_workbook(sheet)
|
||||
try:
|
||||
excel_text = " ".join(
|
||||
str(cell.value or "") for ws in workbook for row in ws for cell in row
|
||||
)
|
||||
finally:
|
||||
workbook.close()
|
||||
for text in (word_text, excel_text):
|
||||
assert "接口数据" in text
|
||||
assert "安卓业务后台" in text
|
||||
assert "actual-step" in text
|
||||
assert "用户指出业务状态仍未落库" in text
|
||||
|
||||
|
||||
def test_reports_share_lifecycle_and_distinguish_guidance_receipt_from_consumption(
|
||||
isolated, tmp_path, monkeypatch
|
||||
):
|
||||
from apps.admin_console.services import task_lifecycle
|
||||
from artemis.runtime import task_control
|
||||
|
||||
job = isolated.jobs(service.create_batch(payload())["id"])[0]
|
||||
job.update(
|
||||
status="completed",
|
||||
created_at=1700000010,
|
||||
worker_started_at=1700000020,
|
||||
ended_at=1700000120,
|
||||
finalized_at=1700000125,
|
||||
)
|
||||
isolated.save_job(job)
|
||||
monkeypatch.setattr(task_control, "TRACES_PATH", reports.TRACES_PATH)
|
||||
task_control.add_instruction(job["session_id"], "保留证据", instruction_id="unused-guidance")
|
||||
report = reports.session_report(job["session_id"])
|
||||
assert report["lifecycle"] == task_lifecycle.task_lifecycle(
|
||||
job["session_id"], db_path=isolated.path
|
||||
)
|
||||
assert report["lifecycle"]["durations"]["total"] == 115
|
||||
assert report["lifecycle"]["durations"]["execution"] is None
|
||||
assert report["instructions"][0]["status"] == "unapplied"
|
||||
word = tmp_path / "lifecycle.docx"
|
||||
excel = tmp_path / "lifecycle.xlsx"
|
||||
reports.word_report(report, word)
|
||||
reports.excel_report(report, excel)
|
||||
document = Document(word)
|
||||
word_text = "\n".join(p.text for p in document.paragraphs) + "\n".join(
|
||||
cell.text for table in document.tables for row in table.rows for cell in row.cells
|
||||
)
|
||||
book = load_workbook(excel)
|
||||
excel_text = "\n".join(str(cell.value) for sheet in book for row in sheet for cell in row)
|
||||
for content in (word_text, excel_text):
|
||||
assert "后台排队:10.0 秒" in content
|
||||
assert "执行与整理:未记录" in content
|
||||
assert "unused-guidance · 任务结束,未记录消费 · 接收" in content
|
||||
|
||||
|
||||
def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
|
||||
isolated, tmp_path, monkeypatch
|
||||
):
|
||||
@@ -1188,6 +1294,7 @@ def test_video_export_accepts_finalized_worker_trace_and_rejects_external_path(
|
||||
second.write_bytes(b"second segment")
|
||||
|
||||
repository = Mock()
|
||||
repository.db_path = isolated.path
|
||||
repository.get_video_recording_for_session.return_value = {"local_video_path": str(first)}
|
||||
repository.get_session_by_id.return_value = {"video_filepath": str(first)}
|
||||
monkeypatch.setattr(reports, "SessionRepository", lambda: repository)
|
||||
|
||||
@@ -99,6 +99,7 @@ async def test_stop_requests_graceful_cancel_and_skips_kill_when_worker_exits():
|
||||
# The worker is asked to cancel itself; nothing is killed yet.
|
||||
terminate.assert_not_called()
|
||||
assert cancel_requests.is_cancel_requested(session_id="sess-1")
|
||||
assert cancel_requests.get_cancel_reason(session_id="sess-1").value == "user_stop"
|
||||
assert cancel_requests.is_cancel_requested(pid=24680, process_created_at=1234.5)
|
||||
assert len(TaskQueueService._forced_stop_tasks) == 1
|
||||
enforcer = next(iter(TaskQueueService._forced_stop_tasks))
|
||||
|
||||
@@ -180,6 +180,37 @@ def test_old_checker_config_does_not_enable_flash_review(monkeypatch, tmp_path):
|
||||
assert load_agent_config(path).flash.final_check_enabled is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enabled", [True, False])
|
||||
def test_saved_flash_switch_reaches_next_worker_without_restarting(
|
||||
isolated_settings, monkeypatch, enabled
|
||||
):
|
||||
from apps.admin_console.services.execution_config import execution_config
|
||||
from apps.admin_console.services.task_queue_service import TaskQueueService
|
||||
|
||||
monkeypatch.setattr("artemis.exploration.store.explorations.run", lambda _sid: None)
|
||||
monkeypatch.setattr("apps.admin_console.services.automation_store.store.job", lambda _sid: None)
|
||||
monkeypatch.setenv("ARTEMIS_FLASH_FINAL_CHECK_ENABLED", str(not enabled).lower())
|
||||
running = {"profile": "flash", "goal": "already running", "session_id": "running-review"}
|
||||
running["execution_config"] = execution_config(running)
|
||||
queued = {"profile": "flash", "goal": "queued", "session_id": "queued-review"}
|
||||
queued["execution_config"] = execution_config(queued)
|
||||
|
||||
service.save_capability_settings(service.CapabilitySettings(flash_final_check_enabled=enabled))
|
||||
# The real queue refreshes pending snapshots just before starting their worker.
|
||||
queued["execution_config"] = execution_config(queued)
|
||||
for item, expected in ((running, not enabled), (queued, enabled)):
|
||||
_cmd, env = TaskQueueService._build_worker_invocation(
|
||||
item,
|
||||
item["session_id"],
|
||||
item["session_id"],
|
||||
item["goal"],
|
||||
"flash",
|
||||
TaskQueueService._task_target(item),
|
||||
)
|
||||
assert env["ARTEMIS_FLASH_FINAL_CHECK_ENABLED"] == str(expected).lower()
|
||||
assert load_agent_config().pro.checker.final_check is True
|
||||
|
||||
|
||||
def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
|
||||
config_path = tmp_path / "artemis.jsonc"
|
||||
config_path.write_text(
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Shared task progress stays read-only and distinguishes receipt from consumption."""
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
import pytest
|
||||
|
||||
from apps.admin_console.routers import tasks
|
||||
from apps.admin_console.services import task_lifecycle as service
|
||||
from artemis.runtime import task_control as control
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def recorded(tmp_path, monkeypatch):
|
||||
db = tmp_path / "只读 progress.db"
|
||||
sid = "recorded-task"
|
||||
with sqlite3.connect(db) as conn:
|
||||
conn.executescript(
|
||||
"CREATE TABLE sessions (session_id TEXT, status TEXT, pid INTEGER, "
|
||||
"start_time REAL, end_time REAL);"
|
||||
"CREATE TABLE automation_jobs (session_id TEXT, payload TEXT);"
|
||||
"CREATE TABLE traces (session_id TEXT, type TEXT, payload TEXT);"
|
||||
)
|
||||
conn.execute("INSERT INTO sessions VALUES (?, 'completed', 17, 20, 120)", (sid,))
|
||||
conn.execute(
|
||||
"INSERT INTO automation_jobs VALUES (?, ?)",
|
||||
(
|
||||
sid,
|
||||
json.dumps(
|
||||
{
|
||||
"status": "completed",
|
||||
"created_at": 10,
|
||||
"worker_started_at": 20,
|
||||
"ended_at": 120,
|
||||
"finalized_at": 125,
|
||||
}
|
||||
),
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO traces VALUES (?, 'final_review_diagnostic', ?)",
|
||||
(sid, json.dumps({"started_at": 90, "ended_at": 110, "status": "completed"})),
|
||||
)
|
||||
monkeypatch.setattr(control, "TRACES_PATH", tmp_path / "traces")
|
||||
monkeypatch.setattr(service, "DB_PATH", db)
|
||||
control._write_atomic(
|
||||
control.control_dir(sid) / "task_control_state.json",
|
||||
{
|
||||
"pid": 17,
|
||||
"status": "running",
|
||||
"timing": {
|
||||
"started_at": 20,
|
||||
"state_started_at": 80,
|
||||
"seconds": {"queued": 10, "task_paused": 20, "running": 30},
|
||||
},
|
||||
},
|
||||
)
|
||||
return db, sid
|
||||
|
||||
|
||||
def test_finished_projection_is_stable_and_does_not_bootstrap_or_change_database(recorded):
|
||||
db, sid = recorded
|
||||
before = db.read_bytes()
|
||||
first = service.task_lifecycle(sid, db_path=str(db), now=500)
|
||||
assert first == service.task_lifecycle(sid, db_path=db, now=900)
|
||||
assert first["durations"] == {
|
||||
"total": 115,
|
||||
"queued": 10,
|
||||
"resource_wait": 10,
|
||||
"paused": 20,
|
||||
"final_review": 20,
|
||||
"execution": 50,
|
||||
"report_export": 5,
|
||||
}
|
||||
assert db.read_bytes() == before
|
||||
|
||||
|
||||
def test_legacy_guidance_array_and_detailed_web_receipt_share_terminal_state(recorded):
|
||||
_, sid = recorded
|
||||
pending = control.add_instruction(sid, "保存现有证据", instruction_id="pending")
|
||||
original = control.control_dir(sid) / "instructions" / "pending.json"
|
||||
before = original.read_bytes()
|
||||
app = FastAPI()
|
||||
app.include_router(tasks.router)
|
||||
client = TestClient(app)
|
||||
details = client.get(f"/api/tasks/{sid}/guidance?details=true").json()
|
||||
legacy = client.get(f"/api/tasks/{sid}/guidance").json()
|
||||
assert details["instructions"] == legacy
|
||||
assert legacy[0]["status"] == "unapplied"
|
||||
assert legacy[0]["accepted_at"] == pending["timestamp"]
|
||||
assert details["lifecycle"]["guidance_counts"]["unapplied"] == 1
|
||||
assert original.read_bytes() == before
|
||||
|
||||
|
||||
def test_state_from_another_worker_does_not_supply_current_timing(recorded):
|
||||
db, sid = recorded
|
||||
state = control.read_state(sid)
|
||||
state["pid"] = 18
|
||||
control._write_atomic(control.control_dir(sid) / "task_control_state.json", state)
|
||||
value = service.task_lifecycle(sid, db_path=db)
|
||||
assert not value["control_timing_recorded"]
|
||||
assert value["durations"]["execution"] is None
|
||||
assert value["durations"]["resource_wait"] is None
|
||||
|
||||
|
||||
def test_missing_database_is_not_created_and_does_not_hide_guidance(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(control, "TRACES_PATH", tmp_path)
|
||||
control.add_instruction("new-task", "等待安全检查点")
|
||||
path = tmp_path / "missing.db"
|
||||
value = service.task_lifecycle("new-task", db_path=path, now=100)
|
||||
assert value["phase"] == "unknown"
|
||||
assert value["guidance_counts"]["pending"] == 1
|
||||
assert not path.exists()
|
||||
Reference in new issue
Block a user