AIgoose commited on
Commit
2d6dbde
·
verified ·
1 Parent(s): d6ebd2c

feat: initial KAZE browser-use HF Space

Browse files
.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # LLM via OpenRouter (same key as KENSHI — no new account needed)
2
+ OPENROUTER_API_KEY=sk-or-...
3
+
4
+ # KENSHI's /api/agent-report URL (where KAZE posts results)
5
+ KENSHI_CALLBACK_URL=https://aigoose-kenshi.hf.space/api/agent-report
6
+
7
+ # Must match KENSHI's AGENT_REPORT_SECRET env var
8
+ KENSHI_CALLBACK_SECRET=your-agent-report-secret
9
+
10
+ # Shared secret: KENSHI sends this in POST body when calling KAZE
11
+ API_SECRET=your-random-api-secret
.pytest_cache/.gitignore ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Created by pytest automatically.
2
+ *
.pytest_cache/CACHEDIR.TAG ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ Signature: 8a477f597d28d172789f06886806bc55
2
+ # This file is a cache directory tag created by pytest.
3
+ # For information about cache directory tags, see:
4
+ # https://bford.info/cachedir/spec.html
.pytest_cache/README.md ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ # pytest cache directory #
2
+
3
+ This directory contains data from the pytest's cache plugin,
4
+ which provides the `--lf` and `--ff` options, as well as the `cache` fixture.
5
+
6
+ **Do not** commit this to version control.
7
+
8
+ See [the docs](https://docs.pytest.org/en/stable/how-to/cache.html) for more information.
.pytest_cache/v/cache/lastfailed ADDED
@@ -0,0 +1 @@
 
 
1
+ {}
.pytest_cache/v/cache/nodeids ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ "tests/test_app.py::test_get_job_returns_404_for_missing_id",
3
+ "tests/test_app.py::test_get_job_returns_job_after_async_dispatch",
4
+ "tests/test_app.py::test_health_returns_ok",
5
+ "tests/test_app.py::test_run_async_queues_job_and_returns_job_id",
6
+ "tests/test_app.py::test_run_async_returns_401_on_wrong_secret",
7
+ "tests/test_app.py::test_run_async_returns_503_when_at_capacity",
8
+ "tests/test_app.py::test_run_sync_returns_401_on_wrong_secret",
9
+ "tests/test_app.py::test_run_sync_returns_503_when_at_capacity",
10
+ "tests/test_browser_agent.py::test_run_task_raises_on_non_rate_limit_error",
11
+ "tests/test_browser_agent.py::test_run_task_retries_with_fallback_on_429",
12
+ "tests/test_browser_agent.py::test_run_task_returns_result_dict",
13
+ "tests/test_jobs.py::test_count_running_reflects_status",
14
+ "tests/test_jobs.py::test_create_job_returns_kaze_prefixed_id",
15
+ "tests/test_jobs.py::test_get_job_after_create",
16
+ "tests/test_jobs.py::test_get_job_returns_none_for_missing_id",
17
+ "tests/test_jobs.py::test_update_job_sets_result_and_finished_at",
18
+ "tests/test_jobs.py::test_update_running_job_has_no_finished_at"
19
+ ]
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim-bookworm
2
+
3
+ RUN apt-get update && apt-get install -y \
4
+ wget gnupg ca-certificates git \
5
+ libnss3 libatk-bridge2.0-0 libgtk-3-0 \
6
+ libgbm-dev libasound2 libglib2.0-0 \
7
+ libx11-6 libx11-xcb1 libxcb1 libxcomposite1 \
8
+ libxcursor1 libxdamage1 libxext6 libxfixes3 \
9
+ libxi6 libxrandr2 libxrender1 libxtst6 \
10
+ libpango-1.0-0 libpangocairo-1.0-0 libxss1 \
11
+ libdrm2 libxkbcommon0 fonts-liberation \
12
+ && rm -rf /var/lib/apt/lists/*
13
+
14
+ WORKDIR /app
15
+
16
+ COPY requirements.txt .
17
+ RUN pip install --no-cache-dir -r requirements.txt
18
+
19
+ ENV PLAYWRIGHT_BROWSERS_PATH=/ms-playwright
20
+ RUN playwright install-deps chromium
21
+ RUN playwright install chromium
22
+ RUN chmod -R 755 /ms-playwright
23
+
24
+ COPY . .
25
+
26
+ RUN mkdir -p data
27
+
28
+ ENV PORT=7860
29
+ EXPOSE 7860
30
+
31
+ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1,10 +1,24 @@
1
  ---
2
- title: Kaze Browser
3
- emoji: 😻
4
- colorFrom: pink
5
- colorTo: red
6
  sdk: docker
 
7
  pinned: false
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: KAZE Browser Agent
3
+ emoji: 🌬️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
+ # KAZE Browser Automation Agent
12
+
13
+ KAZE is a browser automation sub-agent for KENSHI, built on [browser-use](https://github.com/browser-use/browser-use) + FastAPI.
14
+
15
+ ## Endpoints
16
+
17
+ - `GET /health` — liveness check
18
+ - `POST /run` — synchronous task (returns result inline)
19
+ - `POST /run/async` — async task (returns job ID, webhooks back to KENSHI)
20
+ - `GET /job/{job_id}` — poll job status
21
+
22
+ ## Usage
23
+
24
+ KAZE is invoked by KENSHI via the `/browse` Telegram command. It is not intended for direct use.
app.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import hmac
3
+ import logging
4
+ import os
5
+
6
+ import httpx
7
+ from contextlib import asynccontextmanager
8
+ from fastapi import BackgroundTasks, FastAPI, HTTPException
9
+ from pydantic import BaseModel
10
+
11
+ import jobs
12
+ from browser_agent import run_task
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+ MAX_CONCURRENT = 2
17
+ API_SECRET = os.getenv("API_SECRET", "")
18
+ KENSHI_CALLBACK_URL = os.getenv("KENSHI_CALLBACK_URL", "")
19
+ KENSHI_CALLBACK_SECRET = os.getenv("KENSHI_CALLBACK_SECRET", "")
20
+
21
+
22
+ @asynccontextmanager
23
+ async def lifespan(app: FastAPI):
24
+ jobs.init_db()
25
+ yield
26
+
27
+
28
+ app = FastAPI(lifespan=lifespan)
29
+
30
+
31
+ class TaskRequest(BaseModel):
32
+ task: str
33
+ chat_id: str = ""
34
+ callback_url: str = ""
35
+ secret: str = ""
36
+
37
+
38
+ def _verify(secret: str):
39
+ if API_SECRET and not hmac.compare_digest(secret, API_SECRET):
40
+ raise HTTPException(status_code=401, detail="Invalid secret")
41
+
42
+
43
+ @app.get("/health")
44
+ async def health():
45
+ return {
46
+ "status": "ok",
47
+ "model": "google/gemini-flash-1.5",
48
+ "jobs_running": jobs.count_running(),
49
+ }
50
+
51
+
52
+ @app.post("/run")
53
+ async def run_sync(req: TaskRequest):
54
+ _verify(req.secret)
55
+ if jobs.count_running() >= MAX_CONCURRENT:
56
+ raise HTTPException(status_code=503, detail="Max concurrent jobs reached. Try /run/async.")
57
+ job_id = jobs.create_job(req.task, req.chat_id)
58
+ jobs.update_job(job_id, "running")
59
+ try:
60
+ output = await run_task(req.task)
61
+ jobs.update_job(job_id, "done", output["result"])
62
+ return {"status": "done", "result": output["result"], "screenshots": [], "job_id": job_id}
63
+ except asyncio.TimeoutError:
64
+ jobs.update_job(job_id, "timeout", "Task exceeded 120s time limit")
65
+ return {"status": "timeout", "result": "Task exceeded 120s time limit", "screenshots": [], "job_id": job_id}
66
+ except Exception as e:
67
+ jobs.update_job(job_id, "failed", str(e))
68
+ return {"status": "failed", "result": str(e), "screenshots": [], "job_id": job_id}
69
+
70
+
71
+ async def _run_async_job(job_id: str, task: str, chat_id: str, callback_url: str):
72
+ jobs.update_job(job_id, "running")
73
+ try:
74
+ output = await run_task(task)
75
+ jobs.update_job(job_id, "done", output["result"])
76
+ status, result = "done", output["result"]
77
+ except asyncio.TimeoutError:
78
+ jobs.update_job(job_id, "timeout", "Task exceeded 120s time limit")
79
+ status, result = "timeout", "Task exceeded 120s time limit"
80
+ except Exception as e:
81
+ jobs.update_job(job_id, "failed", str(e))
82
+ status, result = "failed", str(e)
83
+
84
+ url = callback_url or KENSHI_CALLBACK_URL
85
+ if url:
86
+ payload = {
87
+ "agentName": "KAZE",
88
+ "taskId": job_id,
89
+ "chatId": chat_id,
90
+ "result": result,
91
+ "status": status,
92
+ }
93
+ headers = {"X-Agent-Secret": KENSHI_CALLBACK_SECRET} if KENSHI_CALLBACK_SECRET else {}
94
+ async with httpx.AsyncClient() as client:
95
+ try:
96
+ await client.post(url, json=payload, headers=headers, timeout=10)
97
+ except Exception as e:
98
+ logger.error("[KAZE] Webhook failed for job %s: %s", job_id, e)
99
+
100
+
101
+ @app.post("/run/async")
102
+ async def run_async_endpoint(req: TaskRequest, background_tasks: BackgroundTasks):
103
+ _verify(req.secret)
104
+ if jobs.count_running() >= MAX_CONCURRENT:
105
+ raise HTTPException(status_code=503, detail="Max concurrent jobs reached.")
106
+ job_id = jobs.create_job(req.task, req.chat_id)
107
+ background_tasks.add_task(_run_async_job, job_id, req.task, req.chat_id, req.callback_url)
108
+ return {"job_id": job_id, "status": "queued"}
109
+
110
+
111
+ @app.get("/job/{job_id}")
112
+ async def get_job(job_id: str):
113
+ job = jobs.get_job(job_id)
114
+ if not job:
115
+ raise HTTPException(status_code=404, detail="Job not found")
116
+ return job
browser_agent.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import os
3
+ import re
4
+
5
+ from langchain_openai import ChatOpenAI
6
+ from browser_use import Agent
7
+
8
+ _HTTP_RETRY_RE = re.compile(r'\b(429|500)\b')
9
+
10
+ OPENROUTER_BASE = "https://openrouter.ai/api/v1"
11
+ PRIMARY_MODEL = "google/gemini-flash-1.5"
12
+ FALLBACK_MODEL = "deepseek/deepseek-chat-v3-0324:free"
13
+
14
+
15
+ def _make_llm(model: str) -> ChatOpenAI:
16
+ api_key = os.getenv("OPENROUTER_API_KEY")
17
+ if not api_key:
18
+ raise EnvironmentError("OPENROUTER_API_KEY environment variable is not set")
19
+ return ChatOpenAI(
20
+ model=model,
21
+ api_key=api_key,
22
+ base_url=OPENROUTER_BASE,
23
+ )
24
+
25
+
26
+ async def _execute(llm: ChatOpenAI, task: str, timeout: int) -> dict:
27
+ agent = Agent(task=task, llm=llm)
28
+ result = await asyncio.wait_for(agent.run(), timeout=timeout)
29
+ return {"result": str(result), "screenshots": []}
30
+
31
+
32
+ async def run_task(task: str, timeout: int = 120) -> dict:
33
+ llm = _make_llm(PRIMARY_MODEL)
34
+ try:
35
+ return await _execute(llm, task, timeout)
36
+ except Exception as e:
37
+ if _HTTP_RETRY_RE.search(str(e)):
38
+ llm = _make_llm(FALLBACK_MODEL)
39
+ return await _execute(llm, task, timeout)
40
+ raise
jobs.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ import uuid
3
+ import os
4
+ from datetime import datetime, timezone
5
+
6
+ DB_PATH = os.path.join(os.path.dirname(__file__), "data", "jobs.db")
7
+
8
+
9
+ def _conn():
10
+ c = sqlite3.connect(DB_PATH, check_same_thread=False)
11
+ c.row_factory = sqlite3.Row
12
+ return c
13
+
14
+
15
+ def init_db():
16
+ os.makedirs(os.path.dirname(DB_PATH), exist_ok=True)
17
+ with _conn() as c:
18
+ c.execute("""
19
+ CREATE TABLE IF NOT EXISTS jobs (
20
+ id TEXT PRIMARY KEY,
21
+ status TEXT NOT NULL DEFAULT 'queued',
22
+ task TEXT NOT NULL,
23
+ chat_id TEXT,
24
+ result TEXT,
25
+ created_at TEXT NOT NULL,
26
+ finished_at TEXT
27
+ )
28
+ """)
29
+
30
+
31
+ def create_job(task: str, chat_id: str) -> str:
32
+ job_id = f"kaze_{uuid.uuid4().hex[:8]}"
33
+ now = datetime.now(timezone.utc).isoformat()
34
+ with _conn() as c:
35
+ c.execute(
36
+ "INSERT INTO jobs (id, task, chat_id, created_at) VALUES (?, ?, ?, ?)",
37
+ (job_id, task, chat_id, now),
38
+ )
39
+ return job_id
40
+
41
+
42
+ def update_job(job_id: str, status: str, result: str = None):
43
+ finished_at = (
44
+ datetime.now(timezone.utc).isoformat()
45
+ if status in ("done", "failed", "timeout")
46
+ else None
47
+ )
48
+ with _conn() as c:
49
+ c.execute(
50
+ "UPDATE jobs SET status=?, result=?, finished_at=? WHERE id=?",
51
+ (status, result, finished_at, job_id),
52
+ )
53
+
54
+
55
+ def get_job(job_id: str) -> dict | None:
56
+ conn = _conn()
57
+ try:
58
+ row = conn.execute("SELECT * FROM jobs WHERE id=?", (job_id,)).fetchone()
59
+ return dict(row) if row else None
60
+ finally:
61
+ conn.close()
62
+
63
+
64
+ def count_running() -> int:
65
+ conn = _conn()
66
+ try:
67
+ return conn.execute(
68
+ "SELECT COUNT(*) FROM jobs WHERE status IN ('running', 'queued')"
69
+ ).fetchone()[0]
70
+ finally:
71
+ conn.close()
requirements-dev.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ pytest>=8.0.0
2
+ pytest-asyncio>=0.23.0
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ browser-use>=0.1.40,<0.3.0
2
+ fastapi>=0.115.0
3
+ uvicorn[standard]>=0.30.0
4
+ httpx>=0.27.0
5
+ python-dotenv>=1.0.0
6
+ langchain-openai>=0.1.20
tests/__init__.py ADDED
File without changes
tests/test_app.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import pytest
3
+ from unittest.mock import patch, AsyncMock
4
+
5
+ os.environ["API_SECRET"] = "test-secret"
6
+ os.environ["OPENROUTER_API_KEY"] = "test-key"
7
+ os.environ["KENSHI_CALLBACK_URL"] = "http://kenshi-test/api/agent-report"
8
+ os.environ["KENSHI_CALLBACK_SECRET"] = "callback-secret"
9
+
10
+ import sys
11
+ from unittest.mock import MagicMock
12
+ sys.modules.setdefault('browser_use', MagicMock())
13
+
14
+ import jobs
15
+ from fastapi.testclient import TestClient
16
+
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def isolated_db(tmp_path, monkeypatch):
20
+ monkeypatch.setattr(jobs, "DB_PATH", str(tmp_path / "test.db"))
21
+ jobs.init_db()
22
+
23
+
24
+ @pytest.fixture
25
+ def client():
26
+ from app import app
27
+ with TestClient(app) as c:
28
+ yield c
29
+
30
+
31
+ def test_health_returns_ok(client):
32
+ r = client.get("/health")
33
+ assert r.status_code == 200
34
+ assert r.json()["status"] == "ok"
35
+ assert "jobs_running" in r.json()
36
+
37
+
38
+ def test_run_async_returns_401_on_wrong_secret(client):
39
+ r = client.post("/run/async", json={
40
+ "task": "browse google.com", "chat_id": "123", "secret": "wrong"
41
+ })
42
+ assert r.status_code == 401
43
+
44
+
45
+ def test_run_async_queues_job_and_returns_job_id(client):
46
+ with patch("app._run_async_job", new_callable=AsyncMock):
47
+ r = client.post("/run/async", json={
48
+ "task": "browse google.com", "chat_id": "123", "secret": "test-secret"
49
+ })
50
+ assert r.status_code == 200
51
+ data = r.json()
52
+ assert data["status"] == "queued"
53
+ assert data["job_id"].startswith("kaze_")
54
+
55
+
56
+ def test_get_job_returns_404_for_missing_id(client):
57
+ r = client.get("/job/kaze_nonexistent")
58
+ assert r.status_code == 404
59
+
60
+
61
+ def test_get_job_returns_job_after_async_dispatch(client):
62
+ with patch("app._run_async_job", new_callable=AsyncMock):
63
+ create = client.post("/run/async", json={
64
+ "task": "find prices on example.com",
65
+ "chat_id": "456",
66
+ "secret": "test-secret",
67
+ })
68
+ job_id = create.json()["job_id"]
69
+ r = client.get(f"/job/{job_id}")
70
+ assert r.status_code == 200
71
+ assert r.json()["task"] == "find prices on example.com"
72
+ assert r.json()["status"] in ("queued", "running")
73
+
74
+
75
+ def test_run_async_returns_503_when_at_capacity(client):
76
+ j1 = jobs.create_job("task 1", "c1")
77
+ j2 = jobs.create_job("task 2", "c2")
78
+ jobs.update_job(j1, "running")
79
+ jobs.update_job(j2, "running")
80
+
81
+ r = client.post("/run/async", json={
82
+ "task": "overflow task", "chat_id": "789", "secret": "test-secret"
83
+ })
84
+ assert r.status_code == 503
85
+
86
+
87
+ def test_run_sync_returns_401_on_wrong_secret(client):
88
+ with patch("app.run_task", new_callable=AsyncMock, return_value={"result": "ok", "screenshots": []}):
89
+ r = client.post("/run", json={
90
+ "task": "browse google.com", "chat_id": "123", "secret": "wrong"
91
+ })
92
+ assert r.status_code == 401
93
+
94
+
95
+ def test_run_sync_returns_503_when_at_capacity(client):
96
+ j1 = jobs.create_job("task 1", "c1")
97
+ j2 = jobs.create_job("task 2", "c2")
98
+ jobs.update_job(j1, "running")
99
+ jobs.update_job(j2, "running")
100
+
101
+ with patch("app.run_task", new_callable=AsyncMock, return_value={"result": "ok", "screenshots": []}):
102
+ r = client.post("/run", json={
103
+ "task": "overflow task", "chat_id": "789", "secret": "test-secret"
104
+ })
105
+ assert r.status_code == 503
tests/test_browser_agent.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from unittest.mock import MagicMock
3
+
4
+ # Stub browser_use before it's imported by browser_agent (not installed locally)
5
+ sys.modules.setdefault("browser_use", MagicMock())
6
+
7
+ import pytest
8
+ import asyncio
9
+ from unittest.mock import patch, AsyncMock
10
+ import os
11
+
12
+ os.environ.setdefault("OPENROUTER_API_KEY", "test-key")
13
+
14
+
15
+ @pytest.mark.asyncio
16
+ async def test_run_task_returns_result_dict():
17
+ mock_agent = MagicMock()
18
+ mock_agent.run = AsyncMock(return_value="Found pricing: $29/mo starter")
19
+
20
+ with patch("browser_agent.Agent", return_value=mock_agent):
21
+ import browser_agent
22
+ result = await browser_agent.run_task("find pricing on example.com")
23
+
24
+ assert result["result"] == "Found pricing: $29/mo starter"
25
+ assert "screenshots" in result
26
+ assert result["screenshots"] == []
27
+
28
+
29
+ @pytest.mark.asyncio
30
+ async def test_run_task_retries_with_fallback_on_429():
31
+ call_count = 0
32
+
33
+ async def flaky_execute(llm, task, timeout):
34
+ nonlocal call_count
35
+ call_count += 1
36
+ if call_count == 1:
37
+ raise Exception("Error 429: rate limit exceeded")
38
+ return {"result": "fallback worked", "screenshots": []}
39
+
40
+ with patch("browser_agent._execute", side_effect=flaky_execute):
41
+ import browser_agent
42
+ result = await browser_agent.run_task("test task")
43
+
44
+ assert result["result"] == "fallback worked"
45
+ assert call_count == 2
46
+
47
+
48
+ @pytest.mark.asyncio
49
+ async def test_run_task_raises_on_non_rate_limit_error():
50
+ async def failing_execute(llm, task, timeout):
51
+ raise ValueError("unexpected error")
52
+
53
+ with patch("browser_agent._execute", side_effect=failing_execute):
54
+ import browser_agent
55
+ with pytest.raises(ValueError, match="unexpected error"):
56
+ await browser_agent.run_task("test task")
tests/test_jobs.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ import jobs
3
+
4
+
5
+ @pytest.fixture(autouse=True)
6
+ def isolated_db(tmp_path, monkeypatch):
7
+ monkeypatch.setattr(jobs, "DB_PATH", str(tmp_path / "test.db"))
8
+ jobs.init_db()
9
+
10
+
11
+ def test_create_job_returns_kaze_prefixed_id():
12
+ job_id = jobs.create_job("test task", "chat123")
13
+ assert job_id.startswith("kaze_")
14
+
15
+
16
+ def test_get_job_after_create():
17
+ job_id = jobs.create_job("test task", "chat123")
18
+ job = jobs.get_job(job_id)
19
+ assert job is not None
20
+ assert job["task"] == "test task"
21
+ assert job["chat_id"] == "chat123"
22
+ assert job["status"] == "queued"
23
+ assert job["finished_at"] is None
24
+
25
+
26
+ def test_update_job_sets_result_and_finished_at():
27
+ job_id = jobs.create_job("test task", "chat123")
28
+ jobs.update_job(job_id, "done", "found pricing: $29/mo")
29
+ job = jobs.get_job(job_id)
30
+ assert job["status"] == "done"
31
+ assert job["result"] == "found pricing: $29/mo"
32
+ assert job["finished_at"] is not None
33
+
34
+
35
+ def test_update_running_job_has_no_finished_at():
36
+ job_id = jobs.create_job("test task", "chat123")
37
+ jobs.update_job(job_id, "running")
38
+ job = jobs.get_job(job_id)
39
+ assert job["status"] == "running"
40
+ assert job["finished_at"] is None
41
+
42
+
43
+ def test_get_job_returns_none_for_missing_id():
44
+ assert jobs.get_job("kaze_nonexistent") is None
45
+
46
+
47
+ def test_count_running_reflects_status():
48
+ # Queued jobs count toward capacity (race-condition fix)
49
+ j1 = jobs.create_job("task 1", "chat1")
50
+ j2 = jobs.create_job("task 2", "chat2")
51
+ assert jobs.count_running() == 2 # both queued
52
+ jobs.update_job(j1, "running")
53
+ assert jobs.count_running() == 2 # one running, one queued
54
+ jobs.update_job(j2, "running")
55
+ assert jobs.count_running() == 2 # both running
56
+ jobs.update_job(j1, "done", "result")
57
+ assert jobs.count_running() == 1 # one done, one still running