feat: 完成工作流调度与上传下载 API
This commit is contained in:
@@ -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)
|
||||
Vendored
+17
@@ -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()
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
import time
|
||||
|
||||
print("WOV_NODE_READY", flush=True)
|
||||
time.sleep(5)
|
||||
Vendored
+17
@@ -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()
|
||||
Vendored
+34
@@ -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()
|
||||
Vendored
+20
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user