naxe 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
naxe/__init__.py ADDED
File without changes
naxe/auth.py ADDED
@@ -0,0 +1,19 @@
1
+ import hashlib
2
+ import os
3
+
4
+ KEY_PREFIX = "naxe_sk_"
5
+
6
+
7
+ def generate_key() -> str:
8
+ """Generate a random API key. Returns the raw key — shown once, never stored."""
9
+ return KEY_PREFIX + os.urandom(32).hex()
10
+
11
+
12
+ def hash_key(raw_key: str) -> str:
13
+ """SHA-256 hash of the raw key. This is what gets stored in the DB."""
14
+ return hashlib.sha256(raw_key.encode()).hexdigest()
15
+
16
+
17
+ def validate_key_format(key: str) -> bool:
18
+ """Check key has correct prefix and length (prefix + 64 hex chars)."""
19
+ return key.startswith(KEY_PREFIX) and len(key) == len(KEY_PREFIX) + 64
naxe/cli_config.py ADDED
@@ -0,0 +1,193 @@
1
+ import os
2
+ import sys
3
+
4
+ from naxe.config import (
5
+ resolve_db_url, resolve_db_url_with_source, write_config_url, _CONFIG_FILE,
6
+ resolve_theme_with_source, write_theme, _THEME_FILE,
7
+ )
8
+
9
+
10
+ def main():
11
+ args = sys.argv[1:]
12
+
13
+ if not args:
14
+ print("Usage: naxe-config <command> [args]")
15
+ print()
16
+ print("Commands:")
17
+ print(" status Show DB connection, auth mode, and job summary")
18
+ print(" set-url <url> Save a DB URL to ~/.config/naxe/config")
19
+ print(" get-url Print the currently resolved DB URL and its source")
20
+ print(" set-theme <name> Save a default theme to ~/.config/naxe/theme")
21
+ print(" get-theme Print the currently resolved theme and its source")
22
+ print()
23
+ print("Agent commands:")
24
+ print(" register-agent <name> Register a new agent and print its API key (shown once)")
25
+ print(" revoke-agent <name> Revoke an agent's API key")
26
+ print(" list-agents List all registered agents")
27
+ sys.exit(1)
28
+
29
+ command = args[0]
30
+
31
+ if command == "status":
32
+ _status()
33
+
34
+ elif command == "set-url":
35
+ if len(args) < 2:
36
+ print("Usage: naxe-config set-url <url>", file=sys.stderr)
37
+ sys.exit(1)
38
+ write_config_url(args[1])
39
+ print(f"Saved to {_CONFIG_FILE}")
40
+
41
+ elif command == "get-url":
42
+ url, source = resolve_db_url_with_source()
43
+ print(f"{url} ({source})")
44
+
45
+ elif command == "set-theme":
46
+ if len(args) < 2:
47
+ print("Usage: naxe-config set-theme <name>", file=sys.stderr)
48
+ print("Built-in naxe themes: naxe, naxe-bold")
49
+ sys.exit(1)
50
+ write_theme(args[1])
51
+ print(f"Saved to {_THEME_FILE}")
52
+
53
+ elif command == "get-theme":
54
+ theme, source = resolve_theme_with_source()
55
+ print(f"{theme} ({source})")
56
+
57
+ elif command == "register-agent":
58
+ if len(args) < 2:
59
+ print("Usage: naxe-config register-agent <name>", file=sys.stderr)
60
+ sys.exit(1)
61
+ _register_agent(args[1])
62
+
63
+ elif command == "revoke-agent":
64
+ if len(args) < 2:
65
+ print("Usage: naxe-config revoke-agent <name>", file=sys.stderr)
66
+ sys.exit(1)
67
+ _revoke_agent(args[1])
68
+
69
+ elif command == "list-agents":
70
+ _list_agents()
71
+
72
+ else:
73
+ print(f"Unknown command: {command}", file=sys.stderr)
74
+ sys.exit(1)
75
+
76
+
77
+ def _status() -> None:
78
+ from naxe.schema import get_connection
79
+ from naxe import store
80
+ from naxe.config import resolve_theme_with_source
81
+
82
+ url, url_source = resolve_db_url_with_source()
83
+ theme, theme_source = resolve_theme_with_source()
84
+
85
+ print(f"Database: {url}")
86
+ print(f" ({url_source})")
87
+
88
+ try:
89
+ conn = get_connection(url, readonly=True)
90
+ except Exception as e:
91
+ print(f"Status: ✗ Cannot connect — {e}")
92
+ sys.exit(1)
93
+
94
+ print(f"Status: ✓ Connected")
95
+
96
+ # Auth mode
97
+ try:
98
+ total_agents = conn.execute("SELECT COUNT(*) AS cnt FROM agents").fetchone()["cnt"]
99
+ active_agents = store.count_active_agents(conn)
100
+ if total_agents == 0:
101
+ print(f"Auth: Open mode (no agents registered)")
102
+ else:
103
+ print(f"Auth: Locked — {active_agents} active agent{'s' if active_agents != 1 else ''}, {total_agents - active_agents} revoked")
104
+ except Exception:
105
+ print(f"Auth: Unknown (agents table not found — run CREATE TABLE manually)")
106
+
107
+ # Job summary
108
+ try:
109
+ total_jobs = conn.execute("SELECT COUNT(*) AS cnt FROM jobs").fetchone()["cnt"]
110
+ active_jobs = conn.execute(
111
+ "SELECT COUNT(*) AS cnt FROM jobs WHERE status NOT IN ('completed', 'cancelled')"
112
+ ).fetchone()["cnt"]
113
+ in_progress_tasks = conn.execute(
114
+ "SELECT COUNT(*) AS cnt FROM tasks WHERE status = 'in_progress'"
115
+ ).fetchone()["cnt"]
116
+ print(f"Jobs: {active_jobs} active, {total_jobs} total")
117
+ if in_progress_tasks:
118
+ print(f"Tasks: {in_progress_tasks} currently in progress")
119
+ except Exception:
120
+ print(f"Jobs: Unknown (schema not initialised)")
121
+
122
+ print(f"Theme: {theme} ({theme_source})")
123
+
124
+
125
+ def _register_agent(name: str) -> None:
126
+ from naxe.schema import get_connection
127
+ from naxe import store, auth
128
+ from naxe.auth import validate_key_format
129
+
130
+ conn = get_connection(resolve_db_url())
131
+
132
+ # If agents are already registered, caller must present a valid key
133
+ # (only an existing agent can register new ones on a locked DB)
134
+ n = store.count_active_agents(conn)
135
+ if n > 0:
136
+ raw_key = os.environ.get("NAXE_API_KEY", "")
137
+ if not raw_key:
138
+ print(
139
+ "naxe-config: NAXE_API_KEY is required to register a new agent when agents are already registered.",
140
+ file=sys.stderr,
141
+ )
142
+ sys.exit(1)
143
+ if not validate_key_format(raw_key):
144
+ print("naxe-config: NAXE_API_KEY has invalid format.", file=sys.stderr)
145
+ sys.exit(1)
146
+ if store.get_agent_by_key_hash(conn, auth.hash_key(raw_key)) is None:
147
+ print("naxe-config: Invalid or revoked API key.", file=sys.stderr)
148
+ sys.exit(1)
149
+
150
+ raw_key = auth.generate_key()
151
+ try:
152
+ store.register_agent(conn, name, auth.hash_key(raw_key))
153
+ conn.commit()
154
+ except ValueError as e:
155
+ print(f"naxe-config: {e}", file=sys.stderr)
156
+ sys.exit(1)
157
+
158
+ print(f"Agent '{name}' registered.")
159
+ print(f"Key: {raw_key}")
160
+ print("Store this securely — it will not be shown again.")
161
+
162
+
163
+ def _revoke_agent(name: str) -> None:
164
+ from naxe.schema import get_connection
165
+ from naxe import store
166
+
167
+ conn = get_connection(resolve_db_url())
168
+ revoked = store.revoke_agent(conn, name)
169
+ if revoked:
170
+ conn.commit()
171
+ print(f"Agent '{name}' revoked.")
172
+ else:
173
+ print(f"naxe-config: Agent '{name}' not found or already revoked.", file=sys.stderr)
174
+ sys.exit(1)
175
+
176
+
177
+ def _list_agents() -> None:
178
+ from naxe.schema import get_connection
179
+ from naxe import store
180
+
181
+ conn = get_connection(resolve_db_url())
182
+ agents = store.list_agents(conn)
183
+
184
+ if not agents:
185
+ print("No agents registered. (Open mode — any caller is accepted.)")
186
+ return
187
+
188
+ print(f"{'Name':<24} {'Created':<20} Status")
189
+ print("-" * 56)
190
+ for a in agents:
191
+ status = "active" if a["active"] else "revoked"
192
+ created = str(a["created_at"])[:19]
193
+ print(f"{a['name']:<24} {created:<20} {status}")
naxe/config.py ADDED
@@ -0,0 +1,61 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ _CONFIG_FILE = Path.home() / ".config" / "naxe" / "config"
5
+ _THEME_FILE = Path.home() / ".config" / "naxe" / "theme"
6
+
7
+ DEFAULT_THEME = "naxe"
8
+
9
+
10
+ def resolve_db_url() -> str:
11
+ if url := os.environ.get("NAXE_DB_URL"):
12
+ return url
13
+ if _CONFIG_FILE.exists():
14
+ url = _CONFIG_FILE.read_text().strip()
15
+ if url:
16
+ return url
17
+ if path := os.environ.get("NAXE_DB_PATH"):
18
+ return path
19
+ return "./naxe.db"
20
+
21
+
22
+ def resolve_db_url_with_source() -> tuple[str, str]:
23
+ if url := os.environ.get("NAXE_DB_URL"):
24
+ return url, "env:NAXE_DB_URL"
25
+ if _CONFIG_FILE.exists():
26
+ url = _CONFIG_FILE.read_text().strip()
27
+ if url:
28
+ return url, f"config:{_CONFIG_FILE}"
29
+ if path := os.environ.get("NAXE_DB_PATH"):
30
+ return path, "env:NAXE_DB_PATH"
31
+ return "./naxe.db", "default"
32
+
33
+
34
+ def write_config_url(url: str) -> None:
35
+ _CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
36
+ _CONFIG_FILE.write_text(url + "\n")
37
+
38
+
39
+ def resolve_theme() -> str:
40
+ if theme := os.environ.get("NAXE_THEME"):
41
+ return theme.strip()
42
+ if _THEME_FILE.exists():
43
+ theme = _THEME_FILE.read_text().strip()
44
+ if theme:
45
+ return theme
46
+ return DEFAULT_THEME
47
+
48
+
49
+ def resolve_theme_with_source() -> tuple[str, str]:
50
+ if theme := os.environ.get("NAXE_THEME"):
51
+ return theme.strip(), "env:NAXE_THEME"
52
+ if _THEME_FILE.exists():
53
+ theme = _THEME_FILE.read_text().strip()
54
+ if theme:
55
+ return theme, f"config:{_THEME_FILE}"
56
+ return DEFAULT_THEME, "default"
57
+
58
+
59
+ def write_theme(theme: str) -> None:
60
+ _THEME_FILE.parent.mkdir(parents=True, exist_ok=True)
61
+ _THEME_FILE.write_text(theme + "\n")
@@ -0,0 +1,81 @@
1
+ from naxe.handlers.jobs import (
2
+ handle_create_job,
3
+ handle_list_jobs,
4
+ handle_edit_job,
5
+ handle_get_job_status,
6
+ handle_cancel_job,
7
+ handle_pause_job,
8
+ handle_resume_job,
9
+ )
10
+ from naxe.handlers.tasks import (
11
+ handle_add_tasks,
12
+ handle_get_next_actions,
13
+ handle_claim_task,
14
+ handle_claim_next_action,
15
+ handle_complete_task,
16
+ handle_fail_task,
17
+ handle_heartbeat_task,
18
+ handle_update_task_progress,
19
+ handle_cancel_task,
20
+ handle_edit_task,
21
+ handle_requeue_task,
22
+ )
23
+ from naxe.handlers.dependencies import (
24
+ handle_add_job_dependency,
25
+ handle_set_job_concurrency,
26
+ handle_set_worktree_paths,
27
+ )
28
+ from naxe.handlers.approval import (
29
+ handle_request_approval,
30
+ handle_approve_task,
31
+ handle_reject_task,
32
+ handle_return_task,
33
+ )
34
+ from naxe.handlers.audit import (
35
+ handle_add_task_comment,
36
+ handle_get_task_comments,
37
+ handle_get_task_events,
38
+ handle_get_job_audit_trail,
39
+ handle_get_blocked_tasks,
40
+ )
41
+ from naxe.handlers.templates import (
42
+ handle_create_job_template,
43
+ handle_list_templates,
44
+ handle_instantiate_template,
45
+ )
46
+
47
+ DISPATCH = {
48
+ "create_job": handle_create_job,
49
+ "list_jobs": handle_list_jobs,
50
+ "edit_job": handle_edit_job,
51
+ "get_job_status": handle_get_job_status,
52
+ "cancel_job": handle_cancel_job,
53
+ "pause_job": handle_pause_job,
54
+ "resume_job": handle_resume_job,
55
+ "add_tasks": handle_add_tasks,
56
+ "get_next_actions": handle_get_next_actions,
57
+ "claim_task": handle_claim_task,
58
+ "claim_next_action": handle_claim_next_action,
59
+ "complete_task": handle_complete_task,
60
+ "fail_task": handle_fail_task,
61
+ "heartbeat_task": handle_heartbeat_task,
62
+ "update_task_progress": handle_update_task_progress,
63
+ "cancel_task": handle_cancel_task,
64
+ "edit_task": handle_edit_task,
65
+ "requeue_task": handle_requeue_task,
66
+ "add_job_dependency": handle_add_job_dependency,
67
+ "set_job_concurrency": handle_set_job_concurrency,
68
+ "set_worktree_paths": handle_set_worktree_paths,
69
+ "request_approval": handle_request_approval,
70
+ "approve_task": handle_approve_task,
71
+ "reject_task": handle_reject_task,
72
+ "return_task": handle_return_task,
73
+ "add_task_comment": handle_add_task_comment,
74
+ "get_task_comments": handle_get_task_comments,
75
+ "get_task_events": handle_get_task_events,
76
+ "get_job_audit_trail": handle_get_job_audit_trail,
77
+ "get_blocked_tasks": handle_get_blocked_tasks,
78
+ "create_job_template": handle_create_job_template,
79
+ "list_templates": handle_list_templates,
80
+ "instantiate_template": handle_instantiate_template,
81
+ }
@@ -0,0 +1,11 @@
1
+ import json
2
+
3
+ from mcp.types import TextContent
4
+
5
+
6
+ def _ok(**kwargs) -> list[TextContent]:
7
+ return [TextContent(type="text", text=json.dumps(kwargs, default=str))]
8
+
9
+
10
+ def _err(msg: str) -> list[TextContent]:
11
+ return [TextContent(type="text", text=json.dumps({"error": msg}))]
@@ -0,0 +1,47 @@
1
+ from typing import Any
2
+
3
+ from naxe.handlers._common import _ok, _err
4
+ from naxe import store
5
+
6
+
7
+ def handle_request_approval(conn, arguments: dict) -> list:
8
+ task_id = arguments["task_id"]
9
+ agent_id = arguments["agent_id"]
10
+ task = store.request_approval(conn, task_id, agent_id, arguments.get("notes"))
11
+ if task is None:
12
+ return _err("Task not found or not eligible for approval request (must be in_progress and owned by this agent)")
13
+ return _ok(success=True, task=task)
14
+
15
+
16
+ def handle_approve_task(conn, arguments: dict) -> list:
17
+ task_id = arguments["task_id"]
18
+ approver_id = arguments["approver_id"]
19
+ result = store.approve_task(conn, task_id, approver_id, arguments.get("notes"))
20
+ if result is None:
21
+ return _err("Task not found or not awaiting approval")
22
+ task = result["task"]
23
+ newly_unblocked = result["newly_unblocked"]
24
+ ret: dict[str, Any] = {"success": True, "task": task, "newly_unblocked": newly_unblocked}
25
+ if task and "_newly_unblocked_jobs" in task:
26
+ ret["newly_unblocked_jobs"] = task.pop("_newly_unblocked_jobs")
27
+ return _ok(**ret)
28
+
29
+
30
+ def handle_reject_task(conn, arguments: dict) -> list:
31
+ task_id = arguments["task_id"]
32
+ approver_id = arguments["approver_id"]
33
+ reason = arguments["reason"]
34
+ task = store.reject_task(conn, task_id, approver_id, reason)
35
+ if task is None:
36
+ return _err("Task not found or not awaiting approval")
37
+ return _ok(success=True, task=task)
38
+
39
+
40
+ def handle_return_task(conn, arguments: dict) -> list:
41
+ task_id = arguments["task_id"]
42
+ approver_id = arguments["approver_id"]
43
+ feedback = arguments["feedback"]
44
+ task = store.return_task(conn, task_id, approver_id, feedback)
45
+ if task is None:
46
+ return _err("Task not found or not awaiting approval")
47
+ return _ok(success=True, task=task)
naxe/handlers/audit.py ADDED
@@ -0,0 +1,48 @@
1
+ from naxe.handlers._common import _ok, _err
2
+ from naxe import store, resolver
3
+
4
+
5
+ def handle_add_task_comment(conn, arguments: dict) -> list:
6
+ task_id = arguments["task_id"]
7
+ author_type = arguments["author_type"]
8
+ if author_type not in ("agent", "human"):
9
+ return _err("author_type must be 'agent' or 'human'")
10
+ comment = store.add_task_comment(
11
+ conn, task_id, arguments["author_id"], author_type, arguments["content"]
12
+ )
13
+ if comment is None:
14
+ return _err(f"Task '{task_id}' not found")
15
+ return _ok(success=True, comment=comment)
16
+
17
+
18
+ def handle_get_task_comments(conn, arguments: dict) -> list:
19
+ task_id = arguments["task_id"]
20
+ task = store.get_task(conn, task_id)
21
+ if not task:
22
+ return _err(f"Task '{task_id}' not found")
23
+ comments = store.get_task_comments(conn, task_id, arguments.get("approval_round"))
24
+ return _ok(task_id=task_id, comments=comments)
25
+
26
+
27
+ def handle_get_task_events(conn, arguments: dict) -> list:
28
+ task_id = arguments["task_id"]
29
+ if not store.get_task(conn, task_id):
30
+ return _err(f"Task '{task_id}' not found")
31
+ events = store.get_task_events(conn, task_id)
32
+ return _ok(task_id=task_id, events=events)
33
+
34
+
35
+ def handle_get_job_audit_trail(conn, arguments: dict) -> list:
36
+ job = store.get_job(conn, arguments["job_id"])
37
+ if not job:
38
+ return _err(f"Job '{arguments['job_id']}' not found")
39
+ events = store.get_job_events(conn, job["id"])
40
+ return _ok(job_id=job["id"], events=events)
41
+
42
+
43
+ def handle_get_blocked_tasks(conn, arguments: dict) -> list:
44
+ job = store.get_job(conn, arguments["job_id"])
45
+ if not job:
46
+ return _err(f"Job '{arguments['job_id']}' not found")
47
+ blocked = resolver.get_blocking_reasons(conn, job["id"])
48
+ return _ok(job_id=job["id"], blocked_tasks=blocked)
@@ -0,0 +1,24 @@
1
+ from naxe.handlers._common import _ok, _err
2
+ from naxe import store
3
+
4
+
5
+ def handle_add_job_dependency(conn, arguments: dict) -> list:
6
+ try:
7
+ store.add_job_dependency(conn, arguments["job_id"], arguments["depends_on_job_id"])
8
+ except ValueError as e:
9
+ return _err(str(e))
10
+ return _ok(success=True, job_id=arguments["job_id"], depends_on_job_id=arguments["depends_on_job_id"])
11
+
12
+
13
+ def handle_set_job_concurrency(conn, arguments: dict) -> list:
14
+ updated = store.set_job_concurrency(conn, arguments["job_id"], arguments.get("max_workers"))
15
+ if updated is None:
16
+ return _err(f"Job '{arguments['job_id']}' not found")
17
+ return _ok(success=True, job=updated)
18
+
19
+
20
+ def handle_set_worktree_paths(conn, arguments: dict) -> list:
21
+ updated = store.set_worktree_paths(conn, arguments["job_id"], arguments.get("paths", {}))
22
+ if updated is None:
23
+ return _err(f"Job '{arguments['job_id']}' not found")
24
+ return _ok(success=True, job=updated)
naxe/handlers/jobs.py ADDED
@@ -0,0 +1,114 @@
1
+ from datetime import datetime, timezone
2
+ from typing import Any
3
+
4
+ from naxe.handlers._common import _ok, _err
5
+ from naxe import store, resolver
6
+ from naxe.schema import TaskStatus
7
+
8
+
9
+ def handle_create_job(conn, arguments: dict) -> list:
10
+ job = store.create_job(
11
+ conn, arguments["name"],
12
+ arguments.get("max_workers"),
13
+ worktree=arguments.get("worktree", False),
14
+ )
15
+ return _ok(job_id=job["id"], job=job)
16
+
17
+
18
+ def handle_list_jobs(conn, arguments: dict) -> list:
19
+ limit = arguments.get("limit", 50)
20
+ offset = arguments.get("offset", 0)
21
+ id_prefix = arguments.get("id_prefix")
22
+ page = store.list_jobs(conn, limit=limit, offset=offset, id_prefix=id_prefix)
23
+ result = []
24
+ for job in page["jobs"]:
25
+ tasks = store.get_tasks_for_job(conn, job["id"])
26
+ result.append({
27
+ **job,
28
+ "progress": {
29
+ "total": len(tasks),
30
+ "completed": sum(1 for t in tasks if t["status"] == TaskStatus.COMPLETED),
31
+ "in_progress": sum(1 for t in tasks if t["status"] == TaskStatus.IN_PROGRESS),
32
+ "pending": sum(1 for t in tasks if t["status"] == TaskStatus.PENDING),
33
+ "failed": sum(1 for t in tasks if t["status"] == TaskStatus.FAILED),
34
+ },
35
+ })
36
+ return _ok(jobs=result, total=page["total"], has_more=page["has_more"])
37
+
38
+
39
+ def handle_edit_job(conn, arguments: dict) -> list:
40
+ updated = store.edit_job(conn, arguments["job_id"], arguments["name"])
41
+ if updated is None:
42
+ return _err(f"Job '{arguments['job_id']}' not found")
43
+ return _ok(success=True, job=updated)
44
+
45
+
46
+ def handle_get_job_status(conn, arguments: dict) -> list:
47
+ job = store.get_job(conn, arguments["job_id"])
48
+ if not job:
49
+ return _err(f"Job '{arguments['job_id']}' not found")
50
+ job_id = job["id"]
51
+ tasks = store.get_tasks_for_job(conn, job_id)
52
+ blocked_tasks = resolver.get_blocking_reasons(conn, job_id)
53
+ blocked_map = {b["id"]: b["blocked_by"] for b in blocked_tasks}
54
+ for t in tasks:
55
+ if t["status"] == TaskStatus.PENDING and t["id"] in blocked_map:
56
+ t["blocked_by"] = blocked_map[t["id"]]
57
+ else:
58
+ t["blocked_by"] = []
59
+ now_iso = datetime.now(timezone.utc).isoformat()
60
+ for t in tasks:
61
+ status = t["status"]
62
+ if status == TaskStatus.PENDING and t["id"] in blocked_map:
63
+ t["display_status"] = "waiting_on"
64
+ elif status == TaskStatus.PENDING and t.get("start_date") and t["start_date"] > now_iso:
65
+ t["display_status"] = "scheduled"
66
+ elif status == TaskStatus.PENDING:
67
+ t["display_status"] = "next_action"
68
+ else:
69
+ t["display_status"] = status
70
+ progress = {
71
+ "total": len(tasks),
72
+ "completed": sum(1 for t in tasks if t["status"] == TaskStatus.COMPLETED),
73
+ "in_progress": sum(1 for t in tasks if t["status"] == TaskStatus.IN_PROGRESS),
74
+ "pending": sum(1 for t in tasks if t["status"] == TaskStatus.PENDING),
75
+ "failed": sum(1 for t in tasks if t["status"] == TaskStatus.FAILED),
76
+ }
77
+ dep_rows = conn.execute(
78
+ """SELECT jd.depends_on_job_id, j.name, j.status
79
+ FROM job_dependencies jd
80
+ JOIN jobs j ON j.id = jd.depends_on_job_id
81
+ WHERE jd.job_id = %s""",
82
+ (job_id,),
83
+ ).fetchall()
84
+ blocking_jobs = [
85
+ {"id": r["depends_on_job_id"], "name": r["name"], "status": r["status"]}
86
+ for r in dep_rows
87
+ if r["status"] != TaskStatus.COMPLETED
88
+ ]
89
+ active_workers = store.count_active_workers(conn, job_id)
90
+ return _ok(job=job, tasks=tasks, progress=progress, blocking_jobs=blocking_jobs, active_workers=active_workers)
91
+
92
+
93
+ def handle_cancel_job(conn, arguments: dict) -> list:
94
+ job_id = arguments["job_id"]
95
+ if not store.get_job(conn, job_id):
96
+ return _err(f"Job '{job_id}' not found")
97
+ result = store.cancel_job(conn, job_id)
98
+ return _ok(success=True, **result)
99
+
100
+
101
+ def handle_pause_job(conn, arguments: dict) -> list:
102
+ job_id = arguments["job_id"]
103
+ job = store.pause_job(conn, job_id, reason=arguments.get("reason"))
104
+ if job is None:
105
+ return _err(f"Job '{job_id}' not found")
106
+ return _ok(success=True, job=job)
107
+
108
+
109
+ def handle_resume_job(conn, arguments: dict) -> list:
110
+ job_id = arguments["job_id"]
111
+ job = store.resume_job(conn, job_id)
112
+ if job is None:
113
+ return _err(f"Job '{job_id}' not found")
114
+ return _ok(success=True, job=job)