mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 10:21:59 +00:00
531 lines
28 KiB
Python
531 lines
28 KiB
Python
"""动作:调用工具节点写外部系统(app/process/actions.py,设计文档 §3.8、§7.2)。
|
||
|
||
用假外部系统(tests/fakes/fake_erp.py)数「它收到了几次写请求」:
|
||
- 确认卡是遮罩后的预览 + 内容指纹;确认之后上游变了,指纹对不上,拒发;
|
||
- 写成功之后超时 → 结果未知,运行挂起(不封存);登记了查询能力的自动核实为已生效、接着跑,外部系统只收到 1 次;
|
||
- 没有查询能力的开核实待办,选「未生效,重新发送」,第 2 次的幂等键带 :2;
|
||
- 探索运行、端点没有测试环境 → 演练,外部系统收到 0 次;
|
||
- 发出之后进程没了 → 重启时收成结果未知;
|
||
- 4xx 确定失败,接着跑也不重发。
|
||
能力登记和平台执行器是治理底座包的,这里用替身(get_capability / endpoint_request)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import timedelta
|
||
import pytest
|
||
from httpx import ASGITransport, AsyncClient
|
||
from sqlalchemy import select
|
||
|
||
from app.api.deps import Principal
|
||
from app.db.base import SessionLocal, utcnow
|
||
from app.db.models import ActionRecord, Run, RunEvent, Task
|
||
from app.engine.runner import ContinueRefused, run_manager
|
||
from app.process import actions, tasks
|
||
from tests.fakes.fake_erp import PAY, FakeErp, endpoint_row, install
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
async def engine_up():
|
||
await run_manager.setup()
|
||
yield
|
||
await run_manager.shutdown()
|
||
|
||
|
||
@pytest.fixture
|
||
def erp() -> FakeErp:
|
||
return FakeErp()
|
||
|
||
|
||
@pytest.fixture
|
||
def capabilities(monkeypatch, erp):
|
||
"""登记付款(写入,遮罩卡号)和按幂等键查付款两个能力;参数是端点、要不要查询能力。"""
|
||
return lambda endpoint_id, *, lookup: install(monkeypatch, erp, endpoint_id, lookup=lookup)
|
||
|
||
|
||
async def _endpoint(*, test: bool) -> str:
|
||
return await endpoint_row(test=test)
|
||
|
||
|
||
def node(nid, ntype, **config):
|
||
return {"id": nid, "type": ntype, "position": {"x": 0, "y": 0}, "data": {"label": nid, "config": config}}
|
||
|
||
|
||
def pay_graph(amount: str = "{{ input.amount }}") -> dict:
|
||
nodes = [node("start", "input", fields=[{"name": "amount"}]),
|
||
node("pay", "tool", tool=PAY, args={"amount": amount, "card": "6222020200112233"}),
|
||
node("out", "output", fields=[{"name": "单号", "value": "{{ nodes.pay.receipt.id }}"}])]
|
||
return {"nodes": nodes, "edges": [{"source": "start", "target": "pay"}, {"source": "pay", "target": "out"}]}
|
||
|
||
|
||
ANN = Principal(username="ann", role="admin", mode="off")
|
||
|
||
|
||
async def _wait(run_id: str, statuses: tuple[str, ...], timeout: float = 20.0) -> Run:
|
||
deadline = asyncio.get_event_loop().time() + timeout
|
||
while asyncio.get_event_loop().time() < deadline:
|
||
async with SessionLocal() as session:
|
||
row = await session.get(Run, run_id)
|
||
if row and row.status in statuses and not run_manager.is_active(run_id):
|
||
return row
|
||
await asyncio.sleep(0.05)
|
||
raise AssertionError(f"运行 {run_id} 没有进入 {statuses}")
|
||
|
||
|
||
async def _task(run_id: str, kind: str) -> Task:
|
||
for _ in range(100):
|
||
async with SessionLocal() as session:
|
||
row = (await session.execute(select(Task).where(Task.run_id == run_id, Task.kind == kind,
|
||
Task.status == "pending"))).scalars().first()
|
||
if row is not None:
|
||
return row
|
||
await asyncio.sleep(0.05)
|
||
raise AssertionError(f"没有开出 {kind} 待办")
|
||
|
||
|
||
async def _records(run_id: str) -> list[ActionRecord]:
|
||
async with SessionLocal() as session:
|
||
return list((await session.execute(select(ActionRecord).where(ActionRecord.run_id == run_id)
|
||
.order_by(ActionRecord.attempt))).scalars())
|
||
|
||
|
||
async def _events(run_id: str, kind: str) -> list[RunEvent]:
|
||
async with SessionLocal() as session:
|
||
return list((await session.execute(select(RunEvent).where(RunEvent.run_id == run_id, RunEvent.type == kind)
|
||
.order_by(RunEvent.seq))).scalars())
|
||
|
||
|
||
async def _confirm(run_id: str) -> Task:
|
||
"""等运行停到确认上、确认卡出来,按卡上的内容指纹确认。待办和审批同一个事务开出来,那一刻运行可能还没收成 interrupted、
|
||
还在跑——这时去确认会被拒(「运行正在运行,没有待处理的审批」):先等运行停下来,再取待办。"""
|
||
await _wait(run_id, ("interrupted",))
|
||
task = await _task(run_id, "action_confirm")
|
||
card = (await tasks.detail(task.id, ANN))["payload"]
|
||
out = await tasks.respond(task.id, ANN, decision="approve", payload_hash=card["payload_hash"])
|
||
assert out["resolved"] is True
|
||
return task
|
||
|
||
|
||
async def _formal(graph: dict, **inputs) -> str:
|
||
run = await run_manager.start(graph=graph, input_payload=inputs or {"amount": 120}, run_class="formal")
|
||
await _wait(run.id, ("interrupted", "failed", "succeeded"))
|
||
return run.id
|
||
|
||
|
||
async def test_confirm_card_is_masked_and_sends_exactly_once(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
run_id = await _formal(pay_graph())
|
||
task = await _task(run_id, "action_confirm")
|
||
card = (await tasks.detail(task.id, ANN))["payload"]
|
||
assert card["kind"] == "action_confirm" and card["environment"] == "prod"
|
||
assert card["preview"] == {"amount": "120", "card": "••••"}
|
||
assert card["payload_hash"] == actions.payload_hash(PAY, {"amount": "120", "card": "6222020200112233"})
|
||
assert erp.requests == [] # 确认之前一次都不发
|
||
|
||
with pytest.raises(tasks.TaskRefused) as wrong:
|
||
await tasks.respond(task.id, ANN, decision="approve", payload_hash="0" * 64)
|
||
assert wrong.value.status_code == 422 and wrong.value.code == "payload_hash_mismatch"
|
||
|
||
await _confirm(run_id)
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
assert final.output["单号"].startswith("PAY-")
|
||
assert len(erp.writes("prod")) == 1
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "confirmed" and rec.attempt == 1 and rec.idem_key == f"{rec.action_key}:1"
|
||
assert rec.preview["card"] == "••••" and rec.confirm_task_id == task.id and rec.receipt["artifact"]
|
||
assert erp.requests[0]["idem_key"] == rec.idem_key
|
||
kinds = [e.type for e in await _events(run_id, "tool.start")]
|
||
assert len(kinds) == 1
|
||
started = (await _events(run_id, "tool.start"))[0].data
|
||
assert started["action_key"] == rec.action_key and started["args"]["card"] == "••••"
|
||
for kind in ("action.prepared", "action.sent", "action.confirmed"):
|
||
assert len(await _events(run_id, kind)) == 1, kind
|
||
|
||
|
||
async def test_changed_upstream_after_confirm_refuses_to_send(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
run_id = await _formal(pay_graph())
|
||
await _task(run_id, "action_confirm")
|
||
async with SessionLocal() as session: # 确认之后上游变了:节点恢复时重新渲染出来的参数不一样
|
||
run = await session.get(Run, run_id)
|
||
graph = dict(run.graph)
|
||
graph["nodes"] = [dict(n) for n in graph["nodes"]]
|
||
pay = next(n for n in graph["nodes"] if n["id"] == "pay")
|
||
pay["data"] = {**pay["data"], "config": {**pay["data"]["config"], "args": {"amount": "999", "card": "x"}}}
|
||
run.graph = graph
|
||
await session.commit()
|
||
await _confirm(run_id)
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "failed" and "内容指纹对不上" in final.error
|
||
assert erp.requests == [] and await _records(run_id) == []
|
||
|
||
|
||
async def test_unknown_result_with_lookup_is_reconciled_automatically_and_sent_once(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=True)
|
||
erp.mode = "timeout_after_write"
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
held = await _wait(run_id, ("interrupted", "failed", "succeeded"))
|
||
assert held.status == "interrupted" and held.manifest_hash is None
|
||
# 运行先挂起、runner 再安排核实(actions.on_unknown):等排上查询(记下 action.unknown)再看记录
|
||
for _ in range(100):
|
||
if await _events(run_id, "action.unknown"):
|
||
break
|
||
await asyncio.sleep(0.05)
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "unknown" and rec.next_lookup_at is not None
|
||
assert len(await _events(run_id, "action.unknown")) == 1
|
||
with pytest.raises(ContinueRefused) as refused:
|
||
await run_manager.continue_failed(run_id)
|
||
assert refused.value.code == "action_unresolved"
|
||
|
||
assert rec.id not in await actions.tick(utcnow()) # 还没到第一次查询的时候
|
||
assert rec.id in await actions.tick(utcnow() + timedelta(minutes=2))
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
assert len(erp.writes()) == 1 and len(erp.lookups) == 1
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "confirmed" and rec.resolution["how"] == "lookup"
|
||
assert final.output["单号"] == erp.payments[("prod", rec.idem_key)]["id"]
|
||
assert len(await _events(run_id, "action.reconciled")) == 1
|
||
|
||
|
||
async def test_without_lookup_manual_resend_uses_attempt_two(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
erp.mode = "timeout_after_write"
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
assert (await _wait(run_id, ("interrupted", "failed", "succeeded"))).status == "interrupted"
|
||
task = await _task(run_id, "reconcile")
|
||
detail = await tasks.detail(task.id, ANN)
|
||
assert detail["payload"]["options"] == ["confirmed", "not_applied_resend", "not_applied_abandon"]
|
||
assert detail["payload"]["preview"]["card"] == "••••"
|
||
|
||
erp.mode = "ok"
|
||
out = await tasks.respond(task.id, ANN, decision="not_applied_resend", note="对方说没收到")
|
||
assert out["resolved"] is True
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
first, second = await _records(run_id)
|
||
assert (first.state, first.resolution["outcome"]) == ("failed", "resend")
|
||
assert second.state == "confirmed" and second.idem_key.endswith(":2")
|
||
assert [r["idem_key"].rsplit(":", 1)[1] for r in erp.writes()] == ["1", "2"]
|
||
assert (await tasks.detail(task.id, ANN))["status"] == "resolved"
|
||
async with SessionLocal() as session: # 接着跑时确认的答复取自断点:内容指纹没变,不再要一次确认
|
||
confirms = (await session.execute(select(Task).where(Task.run_id == run_id,
|
||
Task.kind == "action_confirm"))).scalars().all()
|
||
assert len(confirms) == 1
|
||
|
||
|
||
async def test_manual_confirm_hands_back_the_external_id_without_sending(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
erp.mode = "timeout_after_write"
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
await _wait(run_id, ("interrupted",))
|
||
task = await _task(run_id, "reconcile")
|
||
await tasks.respond(task.id, ANN, decision="confirmed", values={"external_id": "PAY-777"})
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
assert len(erp.writes()) == 1
|
||
|
||
|
||
async def test_exploratory_run_without_test_environment_is_simulated(erp, capabilities):
|
||
capabilities(await _endpoint(test=False), lookup=False)
|
||
run = await run_manager.start(graph=pay_graph(), input_payload={"amount": 5})
|
||
task = await _task(run.id, "action_confirm")
|
||
assert (await tasks.detail(task.id, ANN))["payload"]["environment"] == "simulated"
|
||
await _confirm(run.id)
|
||
final = await _wait(run.id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
assert erp.requests == []
|
||
[rec] = await _records(run.id)
|
||
assert rec.state == "simulated" and rec.receipt["simulated"] is True
|
||
assert len(await _events(run.id, "action.simulated")) == 1
|
||
|
||
|
||
async def test_exploratory_run_with_test_environment_writes_to_test(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
run = await run_manager.start(graph=pay_graph(), input_payload={"amount": 5})
|
||
await _confirm(run.id)
|
||
assert (await _wait(run.id, ("succeeded", "failed"))).status == "succeeded"
|
||
assert len(erp.writes("test")) == 1 and erp.writes("prod") == []
|
||
|
||
|
||
async def test_a_4xx_fails_and_continuing_does_not_resend(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
erp.mode = "error_4xx"
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
final = await _wait(run_id, ("succeeded", "failed", "interrupted"))
|
||
assert final.status == "failed" and "HTTP 422" in final.error
|
||
erp.mode = "ok"
|
||
await run_manager.continue_failed(run_id)
|
||
again = await _wait(run_id, ("succeeded", "failed"))
|
||
assert again.status == "failed" and "不会自动重发" in again.error
|
||
assert len(erp.writes()) == 1
|
||
|
||
|
||
async def test_sent_then_restart_becomes_unknown(capabilities, monkeypatch):
|
||
"""请求发出去、还没回话时进程没了:重启后记录是 sent,收成结果未知、开核实待办,绝不当成没发过。"""
|
||
from app.capabilities import registry
|
||
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
gate = asyncio.Event()
|
||
sent = asyncio.Event()
|
||
|
||
async def hang(*args, **kwargs):
|
||
sent.set()
|
||
await gate.wait()
|
||
|
||
monkeypatch.setattr(registry, "endpoint_request", hang)
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
await asyncio.wait_for(sent.wait(), 10)
|
||
await run_manager.shutdown() # 关停:执行任务被取消,运行挂起
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "sent"
|
||
await run_manager.setup()
|
||
# 启动整理按全库数:同一个测试进程里别的测试留下的结果未知(还没核实)也在里面,这里只看这一次运行的那条
|
||
assert await actions.startup() >= 1
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "unknown"
|
||
await _task(run_id, "reconcile")
|
||
assert len(await _events(run_id, "action.unknown")) == 1
|
||
assert await actions.startup() >= 1 and len(await _events(run_id, "action.unknown")) == 1 # 不重复安排
|
||
|
||
|
||
async def test_continue_api_refuses_while_unresolved(erp, capabilities):
|
||
from app.main import app
|
||
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
erp.mode = "timeout_after_write"
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
await _wait(run_id, ("interrupted",))
|
||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
||
resp = await client.post(f"/api/runs/{run_id}/continue", json={})
|
||
assert resp.status_code == 409 and resp.json()["code"] == "action_unresolved"
|
||
listed = (await client.get(f"/api/actions?run_id={run_id}")).json()["items"]
|
||
assert [r["state"] for r in listed] == ["unknown"] and listed[0]["preview"]["card"] == "••••"
|
||
|
||
|
||
def test_schedule_overlap_says_the_run_is_waiting_for_reconciliation():
|
||
from app.scheduler.service import overlap_reason
|
||
|
||
assert "核实" in overlap_reason("abcdef123", "interrupted", False, True)
|
||
assert "服务重启" in overlap_reason("abcdef123", "interrupted", False)
|
||
|
||
|
||
def test_masking_by_json_pointer():
|
||
doc = {"a": {"b": "secret", "c": [1, "x"]}, "d~e": 1, "f/g": 2}
|
||
out = actions.masked(doc, ["/a/b", "/a/c/1", "/d~0e", "/f~1g", "/missing/x"])
|
||
assert out == {"a": {"b": "••••", "c": [1, "••••"]}, "d~e": "••••", "f/g": "••••"}
|
||
assert doc["a"]["b"] == "secret"
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 参数按能力的参数定义收好类型(typed_args):哈希、预览、发出去的都是收好的那一份
|
||
# --------------------------------------------------------------------------
|
||
|
||
PARAMS = {"type": "object", "required": ["amount"], "properties": {
|
||
"amount": {"type": "number"}, "count": {"type": "integer"}, "urgent": {"type": "boolean"},
|
||
"card": {"type": "string"}, "memo": {"type": "number"},
|
||
"payee": {"type": "object", "properties": {"id": {"type": "integer"}, "vip": {"type": "boolean"}}},
|
||
"splits": {"type": "array", "items": {"type": "number"}}}}
|
||
|
||
|
||
def _cap(schema=PARAMS):
|
||
from types import SimpleNamespace
|
||
|
||
return SimpleNamespace(name=PAY, title="付款", params_schema=schema)
|
||
|
||
|
||
def test_typed_args_follow_the_declared_types():
|
||
out = actions.typed_args(_cap(), {
|
||
"amount": "1280.5", "count": "3", "urgent": "true", "card": "6222", "extra": "原样",
|
||
"payee": '{"id": "42", "vip": "false", "name": "钱七"}', "splits": ["1", 2.5], "memo": ""})
|
||
assert out == {"amount": 1280.5, "count": 3, "urgent": True, "card": "6222", "extra": "原样",
|
||
"payee": {"id": 42, "vip": False, "name": "钱七"}, "splits": [1, 2.5]}
|
||
assert actions.typed_args(_cap(), {"amount": "120"})["amount"] == 120 and \
|
||
isinstance(actions.typed_args(_cap(), {"amount": "120"})["amount"], int)
|
||
assert actions.typed_args(_cap(), {"amount": 7, "count": "4.0"}) == {"amount": 7, "count": 4}
|
||
# 没登记参数定义的能力:原样不动,发出去的和以前一字不差
|
||
assert actions.typed_args(_cap({}), {"amount": "1280.5"}) == {"amount": "1280.5"}
|
||
|
||
|
||
@pytest.mark.parametrize("args, where, want", [
|
||
({"amount": "abc"}, "amount", "应为数"),
|
||
({"amount": "nan"}, "amount", "应为数"),
|
||
({"amount": "1", "count": "2.5"}, "count", "应为整数"),
|
||
({"amount": "1", "urgent": "yes"}, "urgent", "应为是或否"),
|
||
({"amount": "1", "payee": {"id": "x"}}, "payee.id", "应为整数"),
|
||
({"amount": "1", "splits": ["1", "两元"]}, "splits[1]", "应为数"),
|
||
({"amount": "1", "payee": "{坏的"}, "payee", "应为对象(JSON)"),
|
||
({"amount": " "}, "amount", "必填参数"),
|
||
])
|
||
def test_typed_args_refuse_what_cannot_be_the_declared_type(args, where, want):
|
||
with pytest.raises(actions.ActionFailed) as refused:
|
||
actions.typed_args(_cap(), args)
|
||
assert f" {where} " in str(refused.value) and want in str(refused.value) and "没有发送" in str(refused.value)
|
||
|
||
|
||
async def test_a_template_amount_is_sent_as_a_number_and_the_hash_covers_what_is_sent(erp, capabilities):
|
||
caps = capabilities(await _endpoint(test=True), lookup=False)
|
||
caps[PAY].params_schema = {"type": "object", "properties": {"amount": {"type": "number"},
|
||
"card": {"type": "string"}}}
|
||
run_id = await _formal(pay_graph(), amount=1280.5)
|
||
task = await _task(run_id, "action_confirm")
|
||
card = (await tasks.detail(task.id, ANN))["payload"]
|
||
assert card["preview"] == {"amount": 1280.5, "card": "••••"}
|
||
sent = {"amount": 1280.5, "card": "6222020200112233"}
|
||
assert card["payload_hash"] == actions.payload_hash(PAY, sent)
|
||
assert card["payload_hash"] != actions.payload_hash(PAY, {**sent, "amount": "1280.5"})
|
||
await _confirm(run_id)
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
[request] = erp.writes("prod")
|
||
assert request["body"] == sent and isinstance(request["body"]["amount"], float)
|
||
[rec] = await _records(run_id)
|
||
assert rec.payload_hash == card["payload_hash"] and rec.preview["amount"] == 1280.5
|
||
|
||
|
||
async def test_an_untyped_value_fails_the_node_without_sending(erp, capabilities):
|
||
caps = capabilities(await _endpoint(test=True), lookup=False)
|
||
caps[PAY].params_schema = {"type": "object", "properties": {"amount": {"type": "number"}}}
|
||
run_id = await _formal(pay_graph(), amount="abc")
|
||
final = await _wait(run_id, ("succeeded", "failed", "interrupted"))
|
||
assert final.status == "failed" and "参数 amount 应为数" in final.error and "「abc」" in final.error
|
||
assert erp.requests == [] and await _records(run_id) == []
|
||
async with SessionLocal() as session: # 收不成的在确认之前就停下:不开确认卡,看过的不会是发不出去的
|
||
assert (await session.execute(select(Task).where(Task.run_id == run_id))).scalars().all() == []
|
||
|
||
|
||
async def test_a_changed_params_schema_does_not_fail_a_write_that_already_went_out(erp, capabilities):
|
||
"""发出之后能力的参数定义改了(升级时停在结果未知的运行):先查记录再收类型。结果未知的照旧挂起等核实,核实为已生效
|
||
之后节点重新执行交回存下的回执——手上的「1,280」收不成新声明的数,也不把已经生效的写入判成失败。"""
|
||
caps = capabilities(await _endpoint(test=True), lookup=True)
|
||
erp.mode = "timeout_after_write"
|
||
run_id = await _formal(pay_graph(), amount="1,280")
|
||
await _confirm(run_id)
|
||
assert (await _wait(run_id, ("interrupted", "failed", "succeeded"))).status == "interrupted"
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "unknown" and rec.preview["amount"] == "1,280"
|
||
caps[PAY].params_schema = {"type": "object", "properties": {"amount": {"type": "number"}}}
|
||
args = {"amount": "1,280", "card": "6222020200112233"}
|
||
with pytest.raises(actions.ActionPending):
|
||
await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args)
|
||
|
||
assert rec.id in await actions.tick(utcnow() + timedelta(minutes=2))
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
assert len(erp.writes()) == 1
|
||
[rec] = await _records(run_id)
|
||
assert rec.state == "confirmed"
|
||
# 再执行一次(接着跑、重放):交回生效那一次的回执,指纹、预览照记录,不按这一次的参数重算
|
||
prep = await actions.prepare(run_id=run_id, node_id="pay", exec_no=1, capability=caps[PAY], args=args)
|
||
assert prep.receipt is not None and prep.receipt["receipt"]["id"] == final.output["单号"]
|
||
assert (prep.payload_hash, prep.preview["amount"], prep.attempt) == (rec.payload_hash, "1,280", 1)
|
||
# 真要发的(新的一次执行)照旧按参数定义收类型:收不成的不发
|
||
with pytest.raises(actions.ActionFailed, match="参数 amount 应为数"):
|
||
await actions.prepare(run_id=run_id, node_id="pay", exec_no=2, capability=caps[PAY], args=args)
|
||
assert len(erp.writes()) == 1
|
||
|
||
|
||
# --------------------------------------------------------------------------
|
||
# 实例页怎么说:驳回动作确认、结果未知、核实之后
|
||
# --------------------------------------------------------------------------
|
||
|
||
|
||
async def _instance_view(run_id: str) -> dict:
|
||
"""按一页实例页(概况、进度、付款的动作状态)解析这次运行:{组件 id: data}。"""
|
||
from app.apps.resolve import resolve_page
|
||
from app.db.models import App, AppVersion
|
||
|
||
spec = {"format": "agentlab.app/1", "name": "付款", "pages": [{"id": "instance", "title": "实例", "body": [
|
||
{"id": "hdr", "type": "instance_header"}, {"id": "tl", "type": "instance_timeline"},
|
||
{"id": "act", "type": "action_status", "props": {"label": "付款"}, "bind": {"action": "pay"}}]}]}
|
||
async with SessionLocal() as session:
|
||
app = App(name="付款", published_version=1)
|
||
session.add(app)
|
||
await session.flush()
|
||
version = AppVersion(app_id=app.id, version=1, spec=spec, spec_hash="x" * 64)
|
||
session.add(version)
|
||
await session.commit()
|
||
run = await session.get(Run, run_id)
|
||
vm = await resolve_page(session, app, page_id="instance", principal=ANN, role="manage", instance=run,
|
||
version=version)
|
||
return {n["id"]: n.get("data") for n in vm["tree"]}
|
||
|
||
|
||
async def test_a_rejected_confirmation_reads_as_a_decision_not_a_fault(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
run_id = await _formal(pay_graph())
|
||
task = await _task(run_id, "action_confirm")
|
||
await tasks.respond(task.id, ANN, decision="reject", note="金额不对")
|
||
final = await _wait(run_id, ("succeeded", "failed", "cancelled"))
|
||
# 驳回是人的决定,不是故障(05-已知遗留 (a)):运行收成 cancelled + end_reason=rejected,原话照旧写未确认(被驳回),
|
||
# run.cancelled 带上在哪一步、谁、备注;实例页按人的决定说
|
||
assert (final.status, final.end_reason) == ("cancelled", "rejected")
|
||
assert final.error.startswith("「pay」未确认(被驳回),没有发送:金额不对")
|
||
[ended] = await _events(run_id, "run.cancelled")
|
||
assert {k: ended.data[k] for k in ("reason", "node_id", "kind", "decision", "note")} == {
|
||
"reason": "rejected", "node_id": "pay", "kind": "action_confirm", "decision": "reject", "note": "金额不对"}
|
||
assert not await _events(run_id, "run.failed")
|
||
view = await _instance_view(run_id)
|
||
assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("cancelled", "「pay」未确认(被驳回)")
|
||
step = next(s for s in view["tl"]["steps"] if s["node"] == "pay")
|
||
assert (step["state"], step["text"]) == ("rejected", "未确认(被驳回)")
|
||
assert erp.requests == []
|
||
|
||
|
||
async def test_a_rejected_confirmation_with_a_reject_exit_sends_nothing_and_goes_on(erp, capabilities):
|
||
"""动作确认接了「驳回」出口(docs/design/orchestration-round/00-总方案.md §3):驳回时照旧不发送写入、记「未确认(被驳回)」,
|
||
这一步正常结束、沿驳回边往下走(这里是通知申请人),运行照它的路径结束;实例页写「未确认(被驳回),转到「通知申请人」」。"""
|
||
capabilities(await _endpoint(test=True), lookup=False)
|
||
graph = pay_graph()
|
||
graph["nodes"].append(node("tell", "output", fields=[{"name": "结论", "value": "{{ nodes.pay.text }}"}]))
|
||
graph["nodes"][-1]["data"]["label"] = "通知申请人"
|
||
graph["edges"].append({"source": "pay", "target": "tell", "sourceHandle": "rejected"})
|
||
run_id = await _formal(graph)
|
||
task = await _task(run_id, "action_confirm")
|
||
await tasks.respond(task.id, ANN, decision="reject", note="金额不对")
|
||
final = await _wait(run_id, ("succeeded", "failed", "cancelled"))
|
||
assert (final.status, final.end_reason) == ("succeeded", None), final.error
|
||
assert final.output == {"结论": "「pay」未确认(被驳回),没有发送:金额不对"}
|
||
assert erp.requests == [], "驳回之后走出口也不发送"
|
||
assert await _records(run_id) == []
|
||
[done] = [e for e in await _events(run_id, "node.finished") if e.node_id == "pay"]
|
||
assert (done.data["outcome"], done.data["kind"], done.data["decision"]) == ("rejected", "action_confirm", "reject")
|
||
assert not [e for e in await _events(run_id, "tool.start") if e.node_id == "pay"]
|
||
view = await _instance_view(run_id)
|
||
step = next(s for s in view["tl"]["steps"] if s["node"] == "pay")
|
||
assert (step["state"], step["text"]) == ("rejected", "未确认(被驳回),转到「通知申请人」")
|
||
|
||
|
||
async def test_unknown_then_reconciled_reads_as_waiting_then_a_footnote(erp, capabilities):
|
||
capabilities(await _endpoint(test=True), lookup=True)
|
||
erp.mode = "timeout_after_write"
|
||
run_id = await _formal(pay_graph())
|
||
await _confirm(run_id)
|
||
await _wait(run_id, ("interrupted",))
|
||
view = await _instance_view(run_id)
|
||
# 结果未知:在等核实,不是「等待审批」;付款这一步不是「完成」
|
||
assert (view["hdr"]["status_code"], view["hdr"]["status_text"]) == ("waiting", "等待核实写入结果")
|
||
step = next(s for s in view["tl"]["steps"] if s["node"] == "pay")
|
||
assert (step["state"], step["text"]) == ("unknown", "结果未知,等待核实")
|
||
[row] = view["act"]["records"]
|
||
assert row["state"] == "unknown" and "error" not in row and "ReadTimeout" in row["note"]
|
||
|
||
assert row["id"] in await actions.tick(utcnow() + timedelta(minutes=2))
|
||
final = await _wait(run_id, ("succeeded", "failed"))
|
||
assert final.status == "succeeded", final.error
|
||
view = await _instance_view(run_id)
|
||
assert view["hdr"]["status_text"] == "已完成"
|
||
assert next(s for s in view["tl"]["steps"] if s["node"] == "pay")["state"] == "done"
|
||
[row] = view["act"]["records"]
|
||
# 核实为已生效之后:第一次发送超时只作历史记一笔,不再当失败原因
|
||
assert row["state"] == "confirmed" and "error" not in row
|
||
assert "ReadTimeout" in row["note"] and "按幂等键查询核实为已生效" in row["note"]
|