feat: 完成工作流调度与上传下载 API

This commit is contained in:
cat-shark
2026-08-08 21:04:21 +08:00
commit a8d11eafc6
32 changed files with 3675 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
import atexit
import os
import shutil
import tempfile
from pathlib import Path
TEST_ROOT = Path(tempfile.mkdtemp(prefix="wov-api-test-"))
os.environ["WOV_DATA_DIR"] = str(TEST_ROOT / "data")
os.environ["WOV_DB_PATH"] = str(TEST_ROOT / "data" / "wov.db")
os.environ["WOV_STORAGE_DIR"] = str(TEST_ROOT / "storage")
os.environ["WOV_REAP_INTERVAL_SECONDS"] = "0.1"
os.environ["WOV_READY_TIMEOUT_SECONDS"] = "2"
os.environ["WOV_AUTO_SEED"] = "0"
os.environ["WOV_SCHEDULER_ENABLED"] = "0"
def _cleanup() -> None:
shutil.rmtree(TEST_ROOT, ignore_errors=True)
atexit.register(_cleanup)
+17
View File
@@ -0,0 +1,17 @@
import http.server
import threading
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(500)
self.end_headers()
def log_message(self, format, *args) -> None:
return
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+4
View File
@@ -0,0 +1,4 @@
import time
print("WOV_NODE_READY", flush=True)
time.sleep(5)
+17
View File
@@ -0,0 +1,17 @@
import http.server
import threading
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(201)
self.end_headers()
def log_message(self, format, *args) -> None:
return
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+34
View File
@@ -0,0 +1,34 @@
import http.server
import json
import os
import threading
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
if self.path == "/health":
self.send_response(200)
self.end_headers()
return
self.send_response(404)
self.end_headers()
def do_POST(self) -> None:
self.send_response(500)
self.send_header("Content-Type", "application/json")
if os.environ.get("WOV_FAIL_INVALID_JSON") == "1":
body = b"not-json"
else:
body = json.dumps({"status": "failed", "error": "boom"}).encode("utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args) -> None:
return
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+20
View File
@@ -0,0 +1,20 @@
import http.server
import sys
import threading
print("stderr warning", file=sys.stderr, flush=True)
class Handler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(200)
self.end_headers()
def log_message(self, format, *args) -> None:
return
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
print(f"WOV_NODE_READY port={server.server_address[1]}", flush=True)
threading.Thread(target=server.serve_forever, daemon=True).start()
threading.Event().wait()
+97
View File
@@ -0,0 +1,97 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
WORKSPACE = Path(__file__).resolve().parent.parent.parent
ECHO_MANIFEST = json.loads(
(WORKSPACE / "wov-node-echo" / "node.manifest.json").read_text(encoding="utf-8")
)
def test_health() -> None:
with TestClient(app) as client:
root = client.get("/", follow_redirects=False)
assert root.status_code == 200
assert "WOV 应用中心" in root.text
docs = client.get("/docs")
assert docs.status_code == 200
response = client.get("/health")
assert response.status_code == 200
assert response.json()["service"] == "wov-api"
def test_register_validation_errors() -> None:
with TestClient(app) as client:
assert client.post("/api/admin/nodes", json={}).status_code == 422
invalid = dict(ECHO_MANIFEST, id="")
assert client.post("/api/admin/nodes", json=invalid).status_code == 422
invalid = dict(ECHO_MANIFEST, repo_dir="")
assert client.post("/api/admin/nodes", json=invalid).status_code == 422
invalid = dict(ECHO_MANIFEST, max_concurrency=0)
assert client.post("/api/admin/nodes", json=invalid).status_code == 422
def test_node_lifecycle_and_invoke() -> None:
with TestClient(app) as client:
registered = client.post("/api/admin/nodes", json=ECHO_MANIFEST)
assert registered.status_code == 200
assert registered.json()["id"] == "echo"
nodes = client.get("/api/admin/nodes")
assert nodes.status_code == 200
assert [node["id"] for node in nodes.json()] == ["echo"]
node = client.get("/api/admin/nodes/echo")
assert node.status_code == 200
assert node.json()["capability"] == "echo"
assert client.get("/api/admin/nodes/missing").status_code == 404
invoked = client.post(
"/api/admin/nodes/echo/invoke",
json={
"run_id": "run_api",
"inputs": {"text": "api test"},
"params": {},
},
)
assert invoked.status_code == 200
assert invoked.json()["status"] == "completed"
assert invoked.json()["outputs"]["text"] == "api test"
invoked_default = client.post(
"/api/admin/nodes/echo/invoke",
json={"inputs": {}},
)
assert invoked_default.status_code == 200
assert invoked_default.json()["status"] == "completed"
assert invoked_default.json()["outputs"]["text"] == "echo"
instances = client.get("/api/admin/node-instances")
assert instances.status_code == 200
assert len(instances.json()) >= 1
instance_id = instances.json()[0]["id"]
node_instances = client.get("/api/admin/nodes/echo/instances")
assert node_instances.status_code == 200
assert node_instances.json()[0]["id"] == instance_id
assert client.get("/api/admin/nodes/missing/instances").status_code == 404
stopped = client.post(f"/api/admin/node-instances/{instance_id}/stop")
assert stopped.status_code == 200
assert stopped.json()["stopped"] == instance_id
assert client.post("/api/admin/node-instances/missing/stop").status_code == 404
deleted = client.delete("/api/admin/nodes/echo")
assert deleted.status_code == 200
assert deleted.json()["deleted"] == "echo"
assert client.get("/api/admin/nodes/echo").status_code == 404
assert client.delete("/api/admin/nodes/echo").status_code == 404
+141
View File
@@ -0,0 +1,141 @@
import json
from pathlib import Path
from fastapi.testclient import TestClient
from app.main import app
WORKSPACE = Path(__file__).resolve().parent.parent.parent
def _create_published_echo_workflow(client) -> str:
definition = {
"name": "echo-flow",
"version": 1,
"nodes": [
{
"id": "step",
"node_type": "echo",
"inputs": {"file_uri": "input.video_uri"},
}
],
"edges": [],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "step.file_uri"},
}
manifest = json.loads(
(WORKSPACE / "wov-node-echo" / "node.manifest.json").read_text(encoding="utf-8")
)
assert client.post("/api/admin/nodes", json=manifest).status_code == 200
client.post(
"/api/admin/workflows",
json={
"id": "echo-app",
"name": "Echo App",
"description": "upload a file",
"definition": definition,
},
)
client.post("/api/admin/workflows/echo-app/publish")
return "echo-app"
def test_upload_run_progress_and_download() -> None:
with TestClient(app) as client:
workflow_id = _create_published_echo_workflow(client)
apps = client.get("/api/apps")
assert apps.status_code == 200
assert any(item["id"] == workflow_id for item in apps.json())
uploaded = client.post(
f"/api/apps/{workflow_id}/runs",
files={"file": ("sample.txt", b"hello from upload", "text/plain")},
)
assert uploaded.status_code == 200
run_id = uploaded.json()["id"]
assert uploaded.json()["status"] == "QUEUED"
run = client.get(f"/api/runs/{run_id}")
assert run.status_code == 200
assert run.json()["input_uri"].endswith("sample.txt")
assert run.json()["artifacts"] == []
scheduler = app.state.scheduler
scheduler.execute_run(run_id)
completed = client.get(f"/api/runs/{run_id}")
assert completed.status_code == 200
assert completed.json()["status"] == "COMPLETED"
artifact_names = [item["name"] for item in completed.json()["artifacts"]]
assert "result" in artifact_names
artifacts = client.get(f"/api/runs/{run_id}/artifacts")
assert artifacts.status_code == 200
assert len(artifacts.json()) >= 1
downloaded = client.get(f"/api/runs/{run_id}/artifacts/result")
assert downloaded.status_code == 200
assert b"hello from upload" in downloaded.content
assert client.get(f"/api/runs/{run_id}/artifacts/missing").status_code == 404
assert client.get("/api/runs/missing").status_code == 404
assert client.get("/api/runs/missing/artifacts").status_code == 404
db = app.state.db
db.create_artifact(
{
"run_id": run_id,
"node_id": "step",
"name": "missing-file",
"uri": str(Path(__file__).resolve().parent / "not-exists.bin"),
"mime_type": "text/plain",
"size": 0,
}
)
assert client.get(f"/api/runs/{run_id}/artifacts/missing-file").status_code == 404
runs = client.get("/api/runs")
assert runs.status_code == 200
assert any(item["id"] == run_id for item in runs.json())
def test_upload_rejects_unpublished_workflow() -> None:
with TestClient(app) as client:
client.post(
"/api/admin/workflows",
json={
"id": "draft",
"name": "Draft",
"definition": {
"name": "Draft",
"version": 1,
"nodes": [],
"edges": [],
},
},
)
response = client.post(
"/api/apps/draft/runs",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert response.status_code == 404
response = client.post(
"/api/apps/missing/runs",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert response.status_code == 404
def test_upload_rejects_workflow_without_version() -> None:
with TestClient(app) as client:
db = app.state.db
db.upsert_workflow(
{"id": "empty", "name": "Empty", "published": 1, "latest_version": 0}
)
response = client.post(
"/api/apps/empty/runs",
files={"file": ("x.txt", b"x", "text/plain")},
)
assert response.status_code == 422
+144
View File
@@ -0,0 +1,144 @@
from pathlib import Path
from app.db import Database
from wov_sdk.models import NodeManifest
def manifest() -> NodeManifest:
return NodeManifest(
id="echo",
name="Echo",
version="1.0.0",
capability="echo",
command=["python", "-m", "echo"],
repo_dir="wov-node-echo",
)
def test_node_crud(tmp_path) -> None:
db = Database(tmp_path / "wov.db")
assert db.get_node("echo") is None
assert db.list_nodes() == []
db.upsert_node(manifest())
assert db.get_node("echo") == manifest()
assert db.list_nodes() == [manifest()]
updated = manifest()
updated.version = "1.1.0"
db.upsert_node(updated)
assert db.get_node("echo").version == "1.1.0"
db.delete_node("echo")
assert db.get_node("echo") is None
def test_instance_crud(tmp_path) -> None:
db = Database(tmp_path / "wov.db")
db.upsert_node(manifest())
instance = {
"id": "ni_1",
"node_id": "echo",
"status": "ready",
"pid": 123,
"address": "http://127.0.0.1:1",
"started_at": "2026-01-01T00:00:00+00:00",
"last_used_at": "2026-01-01T00:00:00+00:00",
"busy_since": None,
"error": None,
}
db.upsert_instance(instance)
assert db.list_instances() == [instance]
instance["status"] = "stopped"
db.upsert_instance(instance)
assert db.list_instances()[0]["status"] == "stopped"
db.delete_instance("ni_1")
assert db.list_instances() == []
def test_workflow_crud(tmp_path) -> None:
db = Database(tmp_path / "wov.db")
workflow = {
"id": "demo",
"name": "Demo",
"description": "desc",
"published": 0,
"latest_version": 0,
}
db.upsert_workflow(workflow)
assert db.get_workflow("demo")["name"] == "Demo"
assert [item["id"] for item in db.list_workflows()] == ["demo"]
db.upsert_workflow({**workflow, "published": 1, "latest_version": 1})
assert db.get_workflow("demo")["published"] == 1
db.delete_workflow("demo")
assert db.get_workflow("demo") is None
def test_workflow_versions(tmp_path) -> None:
db = Database(tmp_path / "wov.db")
db.upsert_workflow(
{"id": "demo", "name": "Demo", "published": 1, "latest_version": 2}
)
definition = {"name": "Demo", "version": 1, "nodes": [], "edges": []}
db.create_workflow_version("demo", 1, definition)
db.create_workflow_version("demo", 2, {**definition, "version": 2})
latest = db.get_latest_workflow_version("demo")
assert latest["version"] == 2
assert latest["definition"]["version"] == 2
version = db.get_workflow_version("demo", 1)
assert version["version"] == 1
assert db.get_workflow_version("demo", 99) is None
assert len(db.list_workflow_versions("demo")) == 2
empty_db = Database(tmp_path / "empty.db")
assert empty_db.get_latest_workflow_version("missing") is None
def test_run_and_artifact_crud(tmp_path) -> None:
db = Database(tmp_path / "wov.db")
db.upsert_workflow({"id": "demo", "name": "Demo", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "demo",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": "in.txt",
"created_at": now,
"updated_at": now,
}
)
assert db.get_run("run_1")["status"] == "QUEUED"
assert db.next_queued_run()["id"] == "run_1"
db.update_run("run_1", status="RUNNING", progress=0.5, updated_at=now)
db.update_run("run_1")
assert db.get_run("run_1")["status"] == "RUNNING"
assert db.get_run("run_1")["progress"] == 0.5
assert db.next_queued_run() is None
assert len(db.list_runs()) == 1
db.create_artifact(
{
"run_id": "run_1",
"node_id": "echo",
"name": "result",
"uri": "out.txt",
"mime_type": "text/plain",
"size": 3,
}
)
assert db.get_artifact("run_1", "result")["uri"] == "out.txt"
assert db.get_artifact("run_1", "missing") is None
assert len(db.list_artifacts("run_1")) == 1
db.delete_run_artifacts("run_1")
assert db.list_artifacts("run_1") == []
+394
View File
@@ -0,0 +1,394 @@
import subprocess
import sys
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from app.db import Database
from app.node_manager import NodeManager, NodeRuntime, _idle_seconds, _now_iso
from wov_sdk.models import InvokeRequest, NodeManifest
WORKSPACE = Path(__file__).resolve().parent.parent.parent
_BASE_ECHO_MANIFEST = NodeManifest.load(WORKSPACE / "wov-node-echo" / "node.manifest.json")
FIXTURES = Path(__file__).resolve().parent / "fixtures"
@pytest.fixture
def db(tmp_path) -> Database:
return Database(tmp_path / "wov.db")
def _register(db: Database, manifest: NodeManifest) -> None:
db.upsert_node(manifest)
def echo_manifest() -> NodeManifest:
return NodeManifest.from_dict(_BASE_ECHO_MANIFEST.to_dict())
def test_time_helpers() -> None:
now = _now_iso()
assert datetime.fromisoformat(now)
assert _idle_seconds(now) < 0.1
old = (datetime.now(timezone.utc) - timedelta(seconds=10)).isoformat()
assert _idle_seconds(old) >= 9
assert _idle_seconds("") == 0
assert _idle_seconds("invalid") == 0
def test_resolve_command_and_env(db: Database) -> None:
manager = NodeManager(db)
python_command = NodeManifest(
id="x",
name="x",
version="1",
capability="x",
command=["python", "-m", "x"],
)
assert manager._resolve_command(python_command)[0] == sys.executable
fixed_command = NodeManifest(
id="x",
name="x",
version="1",
capability="x",
command=["node", "index.js"],
)
assert manager._resolve_command(fixed_command) == ["node", "index.js"]
env = manager._node_env(python_command)
assert "wov-sdk" in env["PYTHONPATH"]
def test_resolve_command_prefers_node_venv(db: Database, tmp_path, monkeypatch) -> None:
monkeypatch.setattr("app.node_manager.WORKSPACE_ROOT", tmp_path)
repo = tmp_path / "wov-node-demo"
python_exe = repo / ".venv" / "Scripts" / "python.exe"
python_exe.parent.mkdir(parents=True)
python_exe.write_bytes(b"")
manifest = NodeManifest(
id="x",
name="x",
version="1",
capability="x",
command=["python", "-m", "x"],
repo_dir="wov-node-demo",
)
manager = NodeManager(db)
assert manager._resolve_command(manifest)[0] == str(python_exe)
python_exe.unlink()
unix_python = repo / ".venv" / "bin" / "python"
unix_python.parent.mkdir(parents=True)
unix_python.write_bytes(b"")
assert manager._resolve_command(manifest)[0] == str(unix_python)
def test_acquire_unregistered(db: Database) -> None:
manager = NodeManager(db)
with pytest.raises(ValueError):
manager.acquire("missing")
def test_start_missing_repo(db: Database) -> None:
manifest = echo_manifest()
manifest.repo_dir = "missing-repo"
_register(db, manifest)
manager = NodeManager(db)
with pytest.raises(RuntimeError, match="repo_dir not found"):
manager.acquire("echo")
assert db.list_instances()[0]["status"] == "error"
def test_start_command_not_found(db: Database) -> None:
manifest = echo_manifest()
manifest.command = ["definitely-not-a-real-wov-command"]
_register(db, manifest)
manager = NodeManager(db)
with pytest.raises(RuntimeError):
manager.acquire("echo")
assert db.list_instances()[0]["status"] == "error"
def test_start_no_ready_timeout(db: Database, monkeypatch) -> None:
monkeypatch.setattr("app.node_manager.NODE_READY_TIMEOUT_SECONDS", 0.2)
manifest = echo_manifest()
manifest.command = ["python", "-c", "import time; time.sleep(0.5)"]
_register(db, manifest)
manager = NodeManager(db)
with pytest.raises(RuntimeError, match="did not report readiness"):
manager.acquire("echo")
assert db.list_instances()[0]["status"] == "error"
def test_start_bad_ready_line(db: Database) -> None:
manifest = echo_manifest()
manifest.command = ["python", "-u", str(FIXTURES / "bad_ready_node.py")]
_register(db, manifest)
manager = NodeManager(db)
with pytest.raises(RuntimeError, match="did not report readiness"):
manager.acquire("echo")
assert db.list_instances()[0]["status"] == "error"
def test_start_health_check_failure(db: Database) -> None:
manifest = echo_manifest()
manifest.command = ["python", "-u", str(FIXTURES / "bad_node.py")]
_register(db, manifest)
manager = NodeManager(db)
with pytest.raises(RuntimeError, match="health check failed"):
manager.acquire("echo")
assert db.list_instances()[0]["status"] == "error"
def test_start_health_check_bad_status(db: Database) -> None:
manifest = echo_manifest()
manifest.command = ["python", "-u", str(FIXTURES / "bad_status_node.py")]
_register(db, manifest)
manager = NodeManager(db)
with pytest.raises(RuntimeError, match="health check returned 201"):
manager.acquire("echo")
assert db.list_instances()[0]["status"] == "error"
def test_stderr_is_captured(db: Database) -> None:
manifest = echo_manifest()
manifest.command = ["python", "-u", str(FIXTURES / "stderr_node.py")]
_register(db, manifest)
manager = NodeManager(db)
try:
runtime, _ = manager.acquire("echo")
time.sleep(0.2)
runtime = next(iter(manager._runtimes.values()))
assert "stderr warning" in runtime.stderr_tail
manager.release(runtime.instance_id)
finally:
manager.shutdown()
def test_acquire_reuses_ready_instance(db: Database) -> None:
_register(db, echo_manifest())
manager = NodeManager(db)
try:
first, first_address = manager.acquire("echo")
manager.release(first.instance_id)
second, second_address = manager.acquire("echo")
assert first.instance_id == second.instance_id
assert first_address == second_address
manager.release(second.instance_id)
finally:
manager.shutdown()
def test_invoke_success(db: Database, tmp_path) -> None:
_register(db, echo_manifest())
manager = NodeManager(db)
try:
response = manager.invoke(
"echo",
InvokeRequest(
run_id="run_ok",
node_instance_id="",
inputs={"text": "real path"},
output_dir=str(tmp_path),
),
)
assert response.status == "completed"
assert response.outputs["text"] == "real path"
finally:
manager.shutdown()
def test_invoke_http_error(db: Database, tmp_path) -> None:
manifest = echo_manifest()
manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")]
_register(db, manifest)
manager = NodeManager(db)
try:
response = manager.invoke(
"echo",
InvokeRequest(
run_id="run_fail",
node_instance_id="",
inputs={},
output_dir=str(tmp_path),
),
)
assert response.status == "failed"
assert response.error == "boom"
finally:
manager.shutdown()
def test_invoke_http_error_invalid_json(db: Database, tmp_path) -> None:
manifest = echo_manifest()
manifest.command = ["python", "-u", str(FIXTURES / "failing_node.py")]
manifest.env["WOV_FAIL_INVALID_JSON"] = "1"
_register(db, manifest)
manager = NodeManager(db)
try:
response = manager.invoke(
"echo",
InvokeRequest(
run_id="run_bad_json",
node_instance_id="",
inputs={},
output_dir=str(tmp_path),
),
)
assert response.status == "failed"
assert "node returned 500" in response.error
finally:
manager.shutdown()
def test_invoke_network_error(db: Database, tmp_path) -> None:
_register(db, echo_manifest())
manager = NodeManager(db)
try:
runtime, _ = manager.acquire("echo")
manager.release(runtime.instance_id)
runtime.process.terminate()
runtime.process.wait(timeout=5)
response = manager.invoke(
"echo",
InvokeRequest(
run_id="run_net",
node_instance_id="",
inputs={},
output_dir=str(tmp_path),
),
)
assert response.status == "failed"
finally:
manager.shutdown()
def test_release_unknown(db: Database) -> None:
manager = NodeManager(db)
manager.release("missing")
def test_stop_unknown_instance(db: Database) -> None:
manager = NodeManager(db)
manager.stop_instance("missing")
def test_stop_all_for_node(db: Database) -> None:
_register(db, echo_manifest())
manager = NodeManager(db)
runtime, _ = manager.acquire("echo")
manager.stop_all_for_node("echo")
assert runtime.status == "stopped"
assert runtime.instance_id not in manager._runtimes
def test_stop_locked_without_process(db: Database) -> None:
_register(db, echo_manifest())
manager = NodeManager(db)
runtime = NodeRuntime(
instance_id="ni_static",
node_id="echo",
manifest=echo_manifest(),
status="ready",
)
manager._runtimes[runtime.instance_id] = runtime
manager.stop_instance(runtime.instance_id)
assert runtime.status == "stopped"
assert runtime.instance_id not in manager._runtimes
def test_stop_process_already_exited() -> None:
class FakeProcess:
def poll(self):
return 0
manager = object.__new__(NodeManager)
manager._stop_process(FakeProcess())
def test_stop_process_timeout() -> None:
class FakeProcess:
def poll(self):
return None
def terminate(self):
self.terminated = True
def wait(self, timeout):
if not self.killed:
raise subprocess.TimeoutExpired("fake", timeout)
def kill(self):
self.killed = True
fake = FakeProcess()
fake.killed = False
manager = object.__new__(NodeManager)
manager._stop_process(fake)
assert fake.terminated
assert fake.killed
def test_reaper_recycles_idle(db: Database, tmp_path) -> None:
manifest = echo_manifest()
manifest.idle_ttl_seconds = 0
_register(db, manifest)
manager = NodeManager(db)
manager.start_reaper()
try:
response = manager.invoke(
"echo",
InvokeRequest(
run_id="run_reap",
node_instance_id="",
inputs={"text": "reap"},
output_dir=str(tmp_path),
),
)
assert response.status == "completed"
time.sleep(0.4)
assert db.list_instances()[0]["status"] == "stopped"
finally:
manager.shutdown()
def test_reaper_keeps_warm(db: Database, tmp_path) -> None:
manifest = echo_manifest()
manifest.idle_ttl_seconds = 0
manifest.keep_warm = True
_register(db, manifest)
manager = NodeManager(db)
manager.start_reaper()
try:
response = manager.invoke(
"echo",
InvokeRequest(
run_id="run_warm",
node_instance_id="",
inputs={"text": "warm"},
output_dir=str(tmp_path),
),
)
assert response.status == "completed"
time.sleep(0.4)
assert db.list_instances()[0]["status"] == "ready"
finally:
manager.shutdown()
def test_reaper_keeps_busy(db: Database) -> None:
manifest = echo_manifest()
manifest.idle_ttl_seconds = 0
_register(db, manifest)
manager = NodeManager(db)
manager.start_reaper()
try:
runtime, _ = manager.acquire("echo")
time.sleep(0.4)
assert runtime.status == "ready"
manager.release(runtime.instance_id)
finally:
manager.shutdown()
+390
View File
@@ -0,0 +1,390 @@
import time
from pathlib import Path
import pytest
from app.db import Database
from app.node_manager import NodeManager
from app.scheduler import WorkflowScheduler, topological_sort
from wov_sdk.models import (
NodeManifest,
WorkflowDefinition,
WorkflowEdge,
WorkflowNode,
)
def _db(tmp_path) -> Database:
return Database(tmp_path / "wov.db")
def _echo_definition() -> WorkflowDefinition:
return WorkflowDefinition(
name="echo-flow",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="echo",
inputs={"file_uri": "input.video_uri"},
)
],
edges=[],
entry_inputs={"video_uri": "file"},
final_outputs={"result": "step.file_uri"},
)
def test_topological_sort() -> None:
definition = WorkflowDefinition(
name="dag",
version=1,
nodes=[
WorkflowNode(id="a", node_type="x"),
WorkflowNode(id="b", node_type="x"),
WorkflowNode(id="c", node_type="x"),
],
edges=[
WorkflowEdge(from_node="a", to_node="b"),
WorkflowEdge(from_node="a", to_node="c"),
],
)
order = topological_sort(definition)
assert order.index("a") < order.index("b")
assert order.index("a") < order.index("c")
cycle = WorkflowDefinition(
name="cycle",
version=1,
nodes=[
WorkflowNode(id="a", node_type="x"),
WorkflowNode(id="b", node_type="x"),
],
edges=[
WorkflowEdge(from_node="a", to_node="b"),
WorkflowEdge(from_node="b", to_node="a"),
],
)
with pytest.raises(ValueError, match="cycle"):
topological_sort(cycle)
with pytest.raises(ValueError, match="unknown edge"):
topological_sort(
WorkflowDefinition(
name="bad",
version=1,
nodes=[WorkflowNode(id="a", node_type="x")],
edges=[WorkflowEdge(from_node="a", to_node="missing")],
)
)
def test_execute_echo_workflow(tmp_path) -> None:
db = _db(tmp_path)
input_file = tmp_path / "input.txt"
input_file.write_text("hello scheduler", encoding="utf-8")
manifest = NodeManifest.load(
Path(__file__).resolve().parent.parent.parent / "wov-node-echo" / "node.manifest.json"
)
db.upsert_node(manifest)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _echo_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_1",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("run_1")
finally:
manager.shutdown()
run = db.get_run("run_1")
assert run["status"] == "COMPLETED"
artifacts = db.list_artifacts("run_1")
assert {item["name"] for item in artifacts} == {"step.text", "step.file_uri", "result"}
def test_execute_run_missing_workflow(tmp_path, monkeypatch) -> None:
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_missing",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
monkeypatch.setattr(db, "get_workflow", lambda workflow_id: None)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("run_missing")
finally:
manager.shutdown()
assert db.get_run("run_missing")["status"] == "FAILED"
def test_execute_run_missing_version(tmp_path) -> None:
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_version",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("run_version")
finally:
manager.shutdown()
assert db.get_run("run_version")["status"] == "FAILED"
def test_execute_run_missing_node(tmp_path) -> None:
db = _db(tmp_path)
definition = WorkflowDefinition(
name="bad",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="missing-node",
inputs={"text": "input.video_uri"},
)
],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_node",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("run_node")
finally:
manager.shutdown()
assert db.get_run("run_node")["status"] == "FAILED"
def test_resolve_ref_and_mime(tmp_path) -> None:
db = _db(tmp_path)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
assert scheduler._resolve_ref("input.video", "in.mp4", {}) == "in.mp4"
assert (
scheduler._resolve_ref(
"a.out", None, {"a": {"out": "result.txt"}}
)
== "result.txt"
)
assert scheduler._resolve_ref("a.out", None, {}) is None
assert scheduler._resolve_ref("nodot", "in.mp4", {}) is None
assert scheduler._mime_type("x.srt") == "application/x-subrip"
assert scheduler._mime_type("x.ass") == "text/plain"
assert scheduler._mime_type("x.wav") == "audio/wav"
assert scheduler._mime_type("x.mp4") == "video/mp4"
assert scheduler._mime_type("x.txt") == "text/plain"
assert scheduler._mime_type("x.bin") == "application/octet-stream"
existing = tmp_path / "existing.txt"
existing.write_text("x", encoding="utf-8")
assert scheduler._file_size(str(existing)) == 1
missing = tmp_path / "missing.bin"
assert scheduler._file_size(str(missing)) == 0
def test_execute_unknown_or_non_queued_run(tmp_path) -> None:
db = _db(tmp_path)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_done",
"workflow_id": "flow",
"workflow_version": 1,
"status": "COMPLETED",
"progress": 1,
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("missing")
scheduler.execute_run("run_done")
finally:
manager.shutdown()
assert db.get_run("run_done")["status"] == "COMPLETED"
def test_execute_missing_input(tmp_path) -> None:
db = _db(tmp_path)
definition = WorkflowDefinition(
name="missing-input",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="echo",
inputs={"text": "missing.output"},
)
],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_input",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("run_input")
finally:
manager.shutdown()
assert db.get_run("run_input")["status"] == "FAILED"
def test_execute_node_failed_response(tmp_path) -> None:
db = _db(tmp_path)
manifest = NodeManifest(
id="fail-node",
name="Fail",
version="1",
capability="echo",
repo_dir="wov-node-echo",
command=["python", "-u", str(Path(__file__).parent / "fixtures" / "failing_node.py")],
)
db.upsert_node(manifest)
definition = WorkflowDefinition(
name="fail-flow",
version=1,
nodes=[
WorkflowNode(
id="step",
node_type="fail-node",
inputs={"text": "input.video_uri"},
)
],
entry_inputs={"video_uri": "file"},
)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, definition.to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_fail_node",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(tmp_path / "in.txt"),
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage")
try:
scheduler.execute_run("run_fail_node")
finally:
manager.shutdown()
assert db.get_run("run_fail_node")["status"] == "FAILED"
def test_scheduler_start_stop_loop(tmp_path) -> None:
db = _db(tmp_path)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage", interval_seconds=0.05)
scheduler.start()
try:
scheduler.start()
time.sleep(0.15)
finally:
scheduler.stop()
assert scheduler._thread is None
def test_scheduler_background_executes_queued_run(tmp_path) -> None:
db = _db(tmp_path)
input_file = tmp_path / "input.txt"
input_file.write_text("background", encoding="utf-8")
manifest = NodeManifest.load(
Path(__file__).resolve().parent.parent.parent / "wov-node-echo" / "node.manifest.json"
)
db.upsert_node(manifest)
db.upsert_workflow({"id": "flow", "name": "Flow", "published": 1, "latest_version": 1})
db.create_workflow_version("flow", 1, _echo_definition().to_dict())
now = "2026-01-01T00:00:00+00:00"
db.create_run(
{
"id": "run_bg",
"workflow_id": "flow",
"workflow_version": 1,
"status": "QUEUED",
"progress": 0,
"input_uri": str(input_file),
"created_at": now,
"updated_at": now,
}
)
manager = NodeManager(db)
scheduler = WorkflowScheduler(db, manager, tmp_path / "storage", interval_seconds=0.05)
scheduler.start()
try:
deadline = time.monotonic() + 10
while time.monotonic() < deadline:
if db.get_run("run_bg")["status"] in {"COMPLETED", "FAILED"}:
break
time.sleep(0.1)
finally:
scheduler.stop()
manager.shutdown()
assert db.get_run("run_bg")["status"] == "COMPLETED"
+39
View File
@@ -0,0 +1,39 @@
from pathlib import Path
from fastapi.testclient import TestClient
from app.db import Database
from app.main import app
from app.seed import seed_demo_workflow, seed_nodes
WORKSPACE = Path(__file__).resolve().parent.parent.parent
def test_seed_nodes_and_demo_workflow(tmp_path) -> None:
db = Database(tmp_path / "wov.db")
seed_nodes(db, WORKSPACE)
node_ids = [node.id for node in db.list_nodes()]
assert "echo" in node_ids
seed_demo_workflow(db)
workflow = db.get_workflow("demo")
assert workflow is not None
assert workflow["published"] == 1
latest = db.get_latest_workflow_version("demo")
assert latest["definition"]["name"] == "视频字幕生成"
seed_demo_workflow(db)
assert len(db.list_workflow_versions("demo")) == 1
empty_workspace = tmp_path / "empty"
(empty_workspace / "wov-node-empty").mkdir(parents=True)
seed_nodes(db, empty_workspace)
def test_lifespan_with_seed_and_scheduler(monkeypatch) -> None:
monkeypatch.setenv("WOV_AUTO_SEED", "1")
monkeypatch.setenv("WOV_SCHEDULER_ENABLED", "1")
with TestClient(app) as client:
apps = client.get("/api/apps")
assert apps.status_code == 200
assert any(item["id"] == "demo" for item in apps.json())
+27
View File
@@ -0,0 +1,27 @@
import threading
import time
import urllib.request
from uvicorn import Config, Server
from app.main import app
def test_uvicorn_serves_app_over_real_socket() -> None:
config = Config(app=app, host="127.0.0.1", port=0, log_level="error")
server = Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
try:
deadline = time.monotonic() + 10
while not server.started and time.monotonic() < deadline:
time.sleep(0.05)
assert server.started
port = server.servers[0].sockets[0].getsockname()[1]
with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as response:
assert response.status == 200
assert b'"wov-api"' in response.read()
finally:
server.should_exit = True
thread.join(timeout=10)
+107
View File
@@ -0,0 +1,107 @@
from fastapi.testclient import TestClient
from app.main import app
def definition() -> dict:
return {
"name": "echo-flow",
"version": 1,
"nodes": [
{
"id": "step",
"node_type": "echo",
"inputs": {"file_uri": "input.video_uri"},
}
],
"edges": [],
"entry_inputs": {"video_uri": "file"},
"final_outputs": {"result": "step.file_uri"},
}
def test_workflow_crud_and_publish() -> None:
with TestClient(app) as client:
created = client.post(
"/api/admin/workflows",
json={
"id": "echo-flow",
"name": "Echo Flow",
"description": "demo",
"definition": definition(),
},
)
assert created.status_code == 200
assert created.json()["id"] == "echo-flow"
assert client.get("/api/admin/workflows").status_code == 200
assert client.get("/api/admin/workflows/echo-flow").status_code == 200
assert client.get("/api/admin/workflows/missing").status_code == 404
validated = client.post(
"/api/admin/workflows/echo-flow/validate",
json=definition(),
)
assert validated.status_code == 200
assert validated.json()["valid"] is True
assert client.post(
"/api/admin/workflows/missing/validate",
json=definition(),
).status_code == 404
published = client.post("/api/admin/workflows/echo-flow/publish")
assert published.status_code == 200
assert published.json()["published"] == "echo-flow"
assert client.post("/api/admin/workflows/missing/publish").status_code == 404
versions = client.get("/api/admin/workflows/echo-flow/versions")
assert versions.status_code == 200
assert len(versions.json()) == 1
assert client.get("/api/admin/workflows/missing/versions").status_code == 404
assert client.delete("/api/admin/workflows/echo-flow").status_code == 200
assert client.delete("/api/admin/workflows/echo-flow").status_code == 404
def test_workflow_slug_without_id() -> None:
with TestClient(app) as client:
created = client.post(
"/api/admin/workflows",
json={
"name": "Echo Flow",
"definition": definition(),
},
)
assert created.status_code == 200
assert created.json()["id"] == "echo-flow"
def test_workflow_validation_error() -> None:
with TestClient(app) as client:
response = client.post(
"/api/admin/workflows",
json={
"id": "bad",
"name": "Bad",
"definition": {
"name": "Bad",
"version": 1,
"nodes": [
{"id": "a", "node_type": "x"},
{"id": "a", "node_type": "y"},
],
"edges": [],
},
},
)
assert response.status_code == 422
def test_publish_workflow_without_version() -> None:
with TestClient(app) as client:
db = app.state.db
db.upsert_workflow(
{"id": "empty", "name": "Empty", "published": 0, "latest_version": 0}
)
response = client.post("/api/admin/workflows/empty/publish")
assert response.status_code == 422