sudosu 0.1.5__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.
- sudosu/__init__.py +3 -0
- sudosu/cli.py +561 -0
- sudosu/commands/__init__.py +15 -0
- sudosu/commands/agent.py +318 -0
- sudosu/commands/config.py +96 -0
- sudosu/commands/init.py +73 -0
- sudosu/commands/integrations.py +563 -0
- sudosu/commands/memory.py +170 -0
- sudosu/commands/onboarding.py +319 -0
- sudosu/commands/tasks.py +635 -0
- sudosu/core/__init__.py +238 -0
- sudosu/core/agent_loader.py +263 -0
- sudosu/core/connection.py +196 -0
- sudosu/core/default_agent.py +541 -0
- sudosu/core/prompt_refiner.py +0 -0
- sudosu/core/safety.py +75 -0
- sudosu/core/session.py +205 -0
- sudosu/tools/__init__.py +373 -0
- sudosu/ui/__init__.py +451 -0
- sudosu-0.1.5.dist-info/METADATA +172 -0
- sudosu-0.1.5.dist-info/RECORD +25 -0
- sudosu-0.1.5.dist-info/WHEEL +5 -0
- sudosu-0.1.5.dist-info/entry_points.txt +2 -0
- sudosu-0.1.5.dist-info/licenses/LICENSE +21 -0
- sudosu-0.1.5.dist-info/top_level.txt +1 -0
sudosu/commands/tasks.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""CLI commands for managing background tasks.
|
|
2
|
+
|
|
3
|
+
IMPORTANT: These commands query the backend API.
|
|
4
|
+
All task execution happens SERVER-SIDE via ARQ workers.
|
|
5
|
+
|
|
6
|
+
The CLI is a THIN CLIENT - it only displays status and downloads reports.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import httpx
|
|
14
|
+
import typer
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
from rich.panel import Panel
|
|
17
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from sudosu.commands.integrations import get_user_id
|
|
21
|
+
from sudosu.core import get_backend_url
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
app = typer.Typer(help="Manage background tasks")
|
|
25
|
+
|
|
26
|
+
# Status emoji mapping
|
|
27
|
+
STATUS_EMOJI = {
|
|
28
|
+
"pending": "⏳",
|
|
29
|
+
"running": "🔄",
|
|
30
|
+
"completed": "✅",
|
|
31
|
+
"failed": "❌",
|
|
32
|
+
"cancelled": "🚫",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_async(coro):
|
|
37
|
+
"""Run an async coroutine, handling both sync and async contexts.
|
|
38
|
+
|
|
39
|
+
This helper detects if we're already in an event loop (like when called
|
|
40
|
+
from the interactive CLI session) and uses the appropriate method.
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
loop = asyncio.get_running_loop()
|
|
44
|
+
# We're in an async context, create a task
|
|
45
|
+
import concurrent.futures
|
|
46
|
+
with concurrent.futures.ThreadPoolExecutor() as executor:
|
|
47
|
+
future = executor.submit(asyncio.run, coro)
|
|
48
|
+
return future.result()
|
|
49
|
+
except RuntimeError:
|
|
50
|
+
# No running loop, use asyncio.run()
|
|
51
|
+
return asyncio.run(coro)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def get_api_url() -> str:
|
|
55
|
+
"""Get the backend API URL for tasks."""
|
|
56
|
+
backend_url = get_backend_url()
|
|
57
|
+
# Convert ws:// to http:// for REST API
|
|
58
|
+
if backend_url.startswith("ws://"):
|
|
59
|
+
return backend_url.replace("ws://", "http://").replace("/ws", "")
|
|
60
|
+
elif backend_url.startswith("wss://"):
|
|
61
|
+
return backend_url.replace("wss://", "https://").replace("/ws", "")
|
|
62
|
+
return backend_url.replace("/ws", "")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _list_tasks(status: Optional[str], limit: int) -> list[dict]:
|
|
66
|
+
"""Fetch tasks from backend API."""
|
|
67
|
+
user_id = get_user_id()
|
|
68
|
+
if not user_id:
|
|
69
|
+
console.print("[red]Error: Not authenticated. Run 'sudosu init' first.[/red]")
|
|
70
|
+
return []
|
|
71
|
+
|
|
72
|
+
api_url = get_api_url()
|
|
73
|
+
|
|
74
|
+
async with httpx.AsyncClient() as client:
|
|
75
|
+
params = {"user_id": user_id, "limit": limit}
|
|
76
|
+
if status and status != "all":
|
|
77
|
+
params["status"] = status
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
response = await client.get(
|
|
81
|
+
f"{api_url}/api/tasks",
|
|
82
|
+
params=params,
|
|
83
|
+
timeout=30.0,
|
|
84
|
+
)
|
|
85
|
+
response.raise_for_status()
|
|
86
|
+
data = response.json()
|
|
87
|
+
return data.get("tasks", [])
|
|
88
|
+
except httpx.HTTPError as e:
|
|
89
|
+
console.print(f"[red]Error fetching tasks: {e}[/red]")
|
|
90
|
+
return []
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
async def _get_task(task_id: str) -> Optional[dict]:
|
|
94
|
+
"""Fetch a single task from backend API."""
|
|
95
|
+
user_id = get_user_id()
|
|
96
|
+
if not user_id:
|
|
97
|
+
console.print("[red]Error: Not authenticated. Run 'sudosu init' first.[/red]")
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
api_url = get_api_url()
|
|
101
|
+
|
|
102
|
+
async with httpx.AsyncClient() as client:
|
|
103
|
+
try:
|
|
104
|
+
response = await client.get(
|
|
105
|
+
f"{api_url}/api/tasks/{task_id}",
|
|
106
|
+
params={"user_id": user_id},
|
|
107
|
+
timeout=30.0,
|
|
108
|
+
)
|
|
109
|
+
response.raise_for_status()
|
|
110
|
+
return response.json()
|
|
111
|
+
except httpx.HTTPStatusError as e:
|
|
112
|
+
if e.response.status_code == 404:
|
|
113
|
+
console.print(f"[red]Task not found: {task_id}[/red]")
|
|
114
|
+
else:
|
|
115
|
+
console.print(f"[red]Error fetching task: {e}[/red]")
|
|
116
|
+
return None
|
|
117
|
+
except httpx.HTTPError as e:
|
|
118
|
+
console.print(f"[red]Error fetching task: {e}[/red]")
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
async def _get_task_logs(task_id: str, limit: int = 50) -> list[dict]:
|
|
123
|
+
"""Fetch task logs from backend API."""
|
|
124
|
+
user_id = get_user_id()
|
|
125
|
+
if not user_id:
|
|
126
|
+
return []
|
|
127
|
+
|
|
128
|
+
api_url = get_api_url()
|
|
129
|
+
|
|
130
|
+
async with httpx.AsyncClient() as client:
|
|
131
|
+
try:
|
|
132
|
+
response = await client.get(
|
|
133
|
+
f"{api_url}/api/tasks/{task_id}/logs",
|
|
134
|
+
params={"user_id": user_id, "limit": limit},
|
|
135
|
+
timeout=30.0,
|
|
136
|
+
)
|
|
137
|
+
response.raise_for_status()
|
|
138
|
+
data = response.json()
|
|
139
|
+
return data.get("logs", [])
|
|
140
|
+
except httpx.HTTPError as e:
|
|
141
|
+
console.print(f"[red]Error fetching logs: {e}[/red]")
|
|
142
|
+
return []
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def _get_task_report(task_id: str) -> Optional[str]:
|
|
146
|
+
"""Fetch task report from backend API."""
|
|
147
|
+
user_id = get_user_id()
|
|
148
|
+
if not user_id:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
api_url = get_api_url()
|
|
152
|
+
|
|
153
|
+
async with httpx.AsyncClient() as client:
|
|
154
|
+
try:
|
|
155
|
+
response = await client.get(
|
|
156
|
+
f"{api_url}/api/tasks/{task_id}/report",
|
|
157
|
+
params={"user_id": user_id},
|
|
158
|
+
timeout=30.0,
|
|
159
|
+
)
|
|
160
|
+
response.raise_for_status()
|
|
161
|
+
data = response.json()
|
|
162
|
+
return data.get("report")
|
|
163
|
+
except httpx.HTTPStatusError as e:
|
|
164
|
+
if e.response.status_code == 400:
|
|
165
|
+
console.print(f"[yellow]Report not available for this task.[/yellow]")
|
|
166
|
+
else:
|
|
167
|
+
console.print(f"[red]Error fetching report: {e}[/red]")
|
|
168
|
+
return None
|
|
169
|
+
except httpx.HTTPError as e:
|
|
170
|
+
console.print(f"[red]Error fetching report: {e}[/red]")
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _cancel_task(task_id: str) -> bool:
|
|
175
|
+
"""Cancel a task via backend API."""
|
|
176
|
+
user_id = get_user_id()
|
|
177
|
+
if not user_id:
|
|
178
|
+
console.print("[red]Error: Not authenticated. Run 'sudosu init' first.[/red]")
|
|
179
|
+
return False
|
|
180
|
+
|
|
181
|
+
api_url = get_api_url()
|
|
182
|
+
|
|
183
|
+
async with httpx.AsyncClient() as client:
|
|
184
|
+
try:
|
|
185
|
+
response = await client.post(
|
|
186
|
+
f"{api_url}/api/tasks/{task_id}/cancel",
|
|
187
|
+
params={"user_id": user_id},
|
|
188
|
+
timeout=30.0,
|
|
189
|
+
)
|
|
190
|
+
response.raise_for_status()
|
|
191
|
+
return True
|
|
192
|
+
except httpx.HTTPStatusError as e:
|
|
193
|
+
if e.response.status_code == 404:
|
|
194
|
+
console.print(f"[red]Task not found: {task_id}[/red]")
|
|
195
|
+
elif e.response.status_code == 400:
|
|
196
|
+
console.print(f"[yellow]Task cannot be cancelled (already completed/failed).[/yellow]")
|
|
197
|
+
else:
|
|
198
|
+
console.print(f"[red]Error cancelling task: {e}[/red]")
|
|
199
|
+
return False
|
|
200
|
+
except httpx.HTTPError as e:
|
|
201
|
+
console.print(f"[red]Error cancelling task: {e}[/red]")
|
|
202
|
+
return False
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def format_duration(seconds: int) -> str:
|
|
206
|
+
"""Format duration in human-readable form."""
|
|
207
|
+
if seconds < 60:
|
|
208
|
+
return f"{seconds}s"
|
|
209
|
+
elif seconds < 3600:
|
|
210
|
+
return f"{seconds // 60}m {seconds % 60}s"
|
|
211
|
+
else:
|
|
212
|
+
hours = seconds // 3600
|
|
213
|
+
mins = (seconds % 3600) // 60
|
|
214
|
+
return f"{hours}h {mins}m"
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def format_timestamp(iso_str: str) -> str:
|
|
218
|
+
"""Format ISO timestamp for display."""
|
|
219
|
+
if not iso_str:
|
|
220
|
+
return "-"
|
|
221
|
+
try:
|
|
222
|
+
dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
|
|
223
|
+
return dt.strftime("%Y-%m-%d %H:%M")
|
|
224
|
+
except:
|
|
225
|
+
return iso_str[:16]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@app.command("list")
|
|
229
|
+
def list_tasks(
|
|
230
|
+
status: str = typer.Option(
|
|
231
|
+
"all",
|
|
232
|
+
"--status", "-s",
|
|
233
|
+
help="Filter by status: all, pending, running, completed, failed, cancelled"
|
|
234
|
+
),
|
|
235
|
+
limit: int = typer.Option(10, "--limit", "-n", help="Number of tasks to show"),
|
|
236
|
+
full_id: bool = typer.Option(False, "--full-id", "-f", help="Show full task IDs"),
|
|
237
|
+
):
|
|
238
|
+
"""List your background tasks."""
|
|
239
|
+
tasks = run_async(_list_tasks(status if status != "all" else None, limit))
|
|
240
|
+
|
|
241
|
+
if not tasks:
|
|
242
|
+
console.print("\n[dim]No background tasks found.[/dim]\n")
|
|
243
|
+
return
|
|
244
|
+
|
|
245
|
+
table = Table(title="Background Tasks", show_header=True, header_style="bold cyan")
|
|
246
|
+
|
|
247
|
+
# Adjust ID column width based on full_id flag
|
|
248
|
+
id_width = 36 if full_id else 12
|
|
249
|
+
table.add_column("ID", style="dim", width=id_width)
|
|
250
|
+
table.add_column("Status", width=12)
|
|
251
|
+
table.add_column("Task Name", width=35)
|
|
252
|
+
table.add_column("Progress", width=12)
|
|
253
|
+
table.add_column("Created", width=16)
|
|
254
|
+
|
|
255
|
+
for task in tasks:
|
|
256
|
+
status_emoji = STATUS_EMOJI.get(task["status"], "❓")
|
|
257
|
+
status_text = f"{status_emoji} {task['status']}"
|
|
258
|
+
|
|
259
|
+
# Format progress
|
|
260
|
+
if task["status"] == "running":
|
|
261
|
+
progress = f"{task['progress_percent']:.0f}%"
|
|
262
|
+
elif task["status"] == "completed":
|
|
263
|
+
progress = f"✓ {task['completed_items']}/{task['total_items']}"
|
|
264
|
+
elif task["status"] == "failed":
|
|
265
|
+
progress = f"✗ {task['completed_items']}/{task['total_items']}"
|
|
266
|
+
else:
|
|
267
|
+
progress = "-"
|
|
268
|
+
|
|
269
|
+
# Truncate task name
|
|
270
|
+
task_name = task["task_name"]
|
|
271
|
+
if len(task_name) > 33:
|
|
272
|
+
task_name = task_name[:30] + "..."
|
|
273
|
+
|
|
274
|
+
# Show full or truncated ID based on flag
|
|
275
|
+
task_id_display = task["task_id"] if full_id else (task["task_id"][:12] + "...")
|
|
276
|
+
|
|
277
|
+
table.add_row(
|
|
278
|
+
task_id_display,
|
|
279
|
+
status_text,
|
|
280
|
+
task_name,
|
|
281
|
+
progress,
|
|
282
|
+
format_timestamp(task["created_at"]),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
console.print()
|
|
286
|
+
console.print(table)
|
|
287
|
+
console.print()
|
|
288
|
+
if not full_id:
|
|
289
|
+
console.print("[dim]Use 'sudosu tasks status <task_id>' for details[/dim]")
|
|
290
|
+
console.print("[dim]Use --full-id to show complete task IDs for copy/paste[/dim]")
|
|
291
|
+
else:
|
|
292
|
+
console.print("[dim]Use 'sudosu tasks status <task_id>' for details[/dim]")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command("status")
|
|
296
|
+
def task_status(
|
|
297
|
+
task_id: str = typer.Argument(..., help="Task ID (full or partial)"),
|
|
298
|
+
):
|
|
299
|
+
"""Get detailed status of a specific task."""
|
|
300
|
+
# Handle partial task IDs
|
|
301
|
+
if len(task_id) < 36:
|
|
302
|
+
# Need to list and find matching task
|
|
303
|
+
tasks = run_async(_list_tasks(None, 100))
|
|
304
|
+
matches = [t for t in tasks if t["task_id"].startswith(task_id)]
|
|
305
|
+
if len(matches) == 0:
|
|
306
|
+
console.print(f"[red]No task found starting with '{task_id}'[/red]")
|
|
307
|
+
return
|
|
308
|
+
elif len(matches) > 1:
|
|
309
|
+
console.print(f"[yellow]Multiple tasks match '{task_id}':[/yellow]")
|
|
310
|
+
for t in matches[:5]:
|
|
311
|
+
console.print(f" • {t['task_id'][:12]}... - {t['task_name'][:40]}")
|
|
312
|
+
return
|
|
313
|
+
task_id = matches[0]["task_id"]
|
|
314
|
+
|
|
315
|
+
task = run_async(_get_task(task_id))
|
|
316
|
+
if not task:
|
|
317
|
+
return
|
|
318
|
+
|
|
319
|
+
status_emoji = STATUS_EMOJI.get(task["status"], "❓")
|
|
320
|
+
|
|
321
|
+
# Build status panel
|
|
322
|
+
content = f"""
|
|
323
|
+
[bold]{task['task_name']}[/bold]
|
|
324
|
+
|
|
325
|
+
[cyan]Status:[/cyan] {status_emoji} {task['status'].upper()}
|
|
326
|
+
[cyan]Task ID:[/cyan] {task['task_id']}
|
|
327
|
+
[cyan]Type:[/cyan] {task['task_type']}
|
|
328
|
+
|
|
329
|
+
[cyan]Progress:[/cyan] {task['progress_percent']:.1f}%
|
|
330
|
+
[cyan]Items:[/cyan] {task['completed_items']} completed, {task['failed_items']} failed / {task['total_items']} total
|
|
331
|
+
|
|
332
|
+
[cyan]Created:[/cyan] {format_timestamp(task['created_at'])}
|
|
333
|
+
[cyan]Started:[/cyan] {format_timestamp(task.get('started_at', ''))}
|
|
334
|
+
[cyan]Completed:[/cyan] {format_timestamp(task.get('completed_at', ''))}
|
|
335
|
+
"""
|
|
336
|
+
|
|
337
|
+
if task.get("error_message"):
|
|
338
|
+
content += f"\n[red]Error:[/red] {task['error_message']}"
|
|
339
|
+
|
|
340
|
+
console.print()
|
|
341
|
+
console.print(Panel(content, title="Task Status", border_style="cyan"))
|
|
342
|
+
console.print()
|
|
343
|
+
|
|
344
|
+
# Show original prompt
|
|
345
|
+
console.print("[bold]Original Request:[/bold]")
|
|
346
|
+
console.print(Panel(task["original_prompt"], border_style="dim"))
|
|
347
|
+
|
|
348
|
+
# Show result summary if completed
|
|
349
|
+
if task.get("result_summary"):
|
|
350
|
+
console.print("\n[bold]Result Summary:[/bold]")
|
|
351
|
+
console.print(Panel(task["result_summary"], border_style="green"))
|
|
352
|
+
|
|
353
|
+
console.print()
|
|
354
|
+
console.print("[dim]Use 'sudosu tasks logs <task_id>' for execution logs[/dim]")
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
@app.command("logs")
|
|
358
|
+
def task_logs(
|
|
359
|
+
task_id: str = typer.Argument(..., help="Task ID"),
|
|
360
|
+
limit: int = typer.Option(50, "--limit", "-n", help="Number of logs to show"),
|
|
361
|
+
):
|
|
362
|
+
"""View task execution logs."""
|
|
363
|
+
# Handle partial task IDs
|
|
364
|
+
if len(task_id) < 36:
|
|
365
|
+
tasks = run_async(_list_tasks(None, 100))
|
|
366
|
+
matches = [t for t in tasks if t["task_id"].startswith(task_id)]
|
|
367
|
+
if len(matches) == 1:
|
|
368
|
+
task_id = matches[0]["task_id"]
|
|
369
|
+
elif len(matches) == 0:
|
|
370
|
+
console.print(f"[red]No task found starting with '{task_id}'[/red]")
|
|
371
|
+
return
|
|
372
|
+
else:
|
|
373
|
+
console.print(f"[yellow]Multiple tasks match. Use more characters.[/yellow]")
|
|
374
|
+
return
|
|
375
|
+
|
|
376
|
+
logs = run_async(_get_task_logs(task_id, limit))
|
|
377
|
+
|
|
378
|
+
if not logs:
|
|
379
|
+
console.print("\n[dim]No logs found for this task.[/dim]\n")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
console.print(f"\n[bold]Execution Logs[/bold] (Task: {task_id[:8]}...)\n")
|
|
383
|
+
|
|
384
|
+
table = Table(show_header=True, header_style="bold")
|
|
385
|
+
table.add_column("#", style="dim", width=4)
|
|
386
|
+
table.add_column("Status", width=8)
|
|
387
|
+
table.add_column("Tool", width=25)
|
|
388
|
+
table.add_column("Message", width=40)
|
|
389
|
+
table.add_column("Duration", width=10)
|
|
390
|
+
|
|
391
|
+
for log in logs:
|
|
392
|
+
status_icon = "✓" if log["status"] == "success" else "✗"
|
|
393
|
+
status_style = "green" if log["status"] == "success" else "red"
|
|
394
|
+
|
|
395
|
+
duration = f"{log['duration_ms']}ms" if log.get("duration_ms") else "-"
|
|
396
|
+
message = log.get("message", "-")
|
|
397
|
+
if len(message) > 38:
|
|
398
|
+
message = message[:35] + "..."
|
|
399
|
+
|
|
400
|
+
table.add_row(
|
|
401
|
+
str(log["iteration"]),
|
|
402
|
+
f"[{status_style}]{status_icon}[/{status_style}]",
|
|
403
|
+
log.get("tool_name", "-"),
|
|
404
|
+
message,
|
|
405
|
+
duration,
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
console.print(table)
|
|
409
|
+
console.print()
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@app.command("report")
|
|
413
|
+
def task_report(
|
|
414
|
+
task_id: str = typer.Argument(..., help="Task ID"),
|
|
415
|
+
):
|
|
416
|
+
"""Download and display task report."""
|
|
417
|
+
# Handle partial task IDs
|
|
418
|
+
if len(task_id) < 36:
|
|
419
|
+
tasks = run_async(_list_tasks(None, 100))
|
|
420
|
+
matches = [t for t in tasks if t["task_id"].startswith(task_id)]
|
|
421
|
+
if len(matches) == 1:
|
|
422
|
+
task_id = matches[0]["task_id"]
|
|
423
|
+
elif len(matches) == 0:
|
|
424
|
+
console.print(f"[red]No task found starting with '{task_id}'[/red]")
|
|
425
|
+
return
|
|
426
|
+
else:
|
|
427
|
+
console.print(f"[yellow]Multiple tasks match. Use more characters.[/yellow]")
|
|
428
|
+
return
|
|
429
|
+
|
|
430
|
+
report = run_async(_get_task_report(task_id))
|
|
431
|
+
|
|
432
|
+
if not report:
|
|
433
|
+
return
|
|
434
|
+
|
|
435
|
+
console.print(f"\n[bold]Task Report[/bold] (ID: {task_id[:8]}...)\n")
|
|
436
|
+
console.print(Panel(report, border_style="green"))
|
|
437
|
+
console.print()
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
@app.command("cancel")
|
|
441
|
+
def cancel_task(
|
|
442
|
+
task_id: str = typer.Argument(..., help="Task ID to cancel (full or partial)"),
|
|
443
|
+
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt"),
|
|
444
|
+
):
|
|
445
|
+
"""Cancel a pending or running task.
|
|
446
|
+
|
|
447
|
+
This will:
|
|
448
|
+
- Mark the task as cancelled in the database
|
|
449
|
+
- Stop execution at the next checkpoint (before the next tool call)
|
|
450
|
+
- Prevent the task from running if it hasn't started yet
|
|
451
|
+
|
|
452
|
+
Note: Tasks in the middle of a tool call will complete that call before stopping.
|
|
453
|
+
"""
|
|
454
|
+
# Handle partial task IDs
|
|
455
|
+
if len(task_id) < 36:
|
|
456
|
+
tasks = run_async(_list_tasks(None, 100))
|
|
457
|
+
matches = [t for t in tasks if t["task_id"].startswith(task_id)]
|
|
458
|
+
if len(matches) == 1:
|
|
459
|
+
task_id = matches[0]["task_id"]
|
|
460
|
+
elif len(matches) == 0:
|
|
461
|
+
console.print(f"[red]No task found starting with '{task_id}'[/red]")
|
|
462
|
+
return
|
|
463
|
+
else:
|
|
464
|
+
console.print(f"[yellow]Multiple tasks match '{task_id}':[/yellow]")
|
|
465
|
+
for t in matches[:5]:
|
|
466
|
+
console.print(f" • {t['task_id']} - {t['task_name'][:40]}")
|
|
467
|
+
console.print(f"\n[dim]Use more characters to uniquely identify the task[/dim]")
|
|
468
|
+
return
|
|
469
|
+
|
|
470
|
+
# Get task details
|
|
471
|
+
task = run_async(_get_task(task_id))
|
|
472
|
+
if not task:
|
|
473
|
+
return
|
|
474
|
+
|
|
475
|
+
# Check if task can be cancelled
|
|
476
|
+
if task["status"] not in ("pending", "running"):
|
|
477
|
+
console.print(f"\n[yellow]This task cannot be cancelled (status: {task['status']})[/yellow]")
|
|
478
|
+
console.print("[dim]Only pending or running tasks can be cancelled.[/dim]")
|
|
479
|
+
return
|
|
480
|
+
|
|
481
|
+
# Show confirmation unless force flag is set
|
|
482
|
+
if not force:
|
|
483
|
+
console.print(f"\n[yellow]⚠ Cancel this task?[/yellow]")
|
|
484
|
+
console.print(f" [bold]Task:[/bold] {task['task_name']}")
|
|
485
|
+
console.print(f" [bold]Status:[/bold] {task['status']}")
|
|
486
|
+
console.print(f" [bold]Progress:[/bold] {task['completed_items']}/{task['total_items']} items")
|
|
487
|
+
console.print()
|
|
488
|
+
|
|
489
|
+
confirm = typer.confirm("Cancel this task?", default=False)
|
|
490
|
+
if not confirm:
|
|
491
|
+
console.print("[dim]Cancelled.[/dim]")
|
|
492
|
+
return
|
|
493
|
+
|
|
494
|
+
# Cancel the task
|
|
495
|
+
success = run_async(_cancel_task(task_id))
|
|
496
|
+
|
|
497
|
+
if success:
|
|
498
|
+
console.print(f"\n[green]✓ Task cancelled successfully[/green]")
|
|
499
|
+
console.print(f" [bold]Task ID:[/bold] {task_id}")
|
|
500
|
+
console.print()
|
|
501
|
+
console.print("[cyan]What happens now:[/cyan]")
|
|
502
|
+
if task["status"] == "pending":
|
|
503
|
+
console.print(" • Task was not yet started - it will be skipped")
|
|
504
|
+
else:
|
|
505
|
+
console.print(" • Task will stop before the next tool call")
|
|
506
|
+
console.print(" • Current operation will complete gracefully")
|
|
507
|
+
console.print()
|
|
508
|
+
console.print(f"[dim]Use 'sudosu tasks status {task_id[:12]}' to verify cancellation[/dim]")
|
|
509
|
+
else:
|
|
510
|
+
console.print(f"\n[red]✗ Failed to cancel task[/red]")
|
|
511
|
+
console.print("[dim]The task may have already completed or been cancelled.[/dim]")
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
@app.command("watch")
|
|
515
|
+
def watch_task(
|
|
516
|
+
task_id: str = typer.Argument(..., help="Task ID to watch"),
|
|
517
|
+
interval: int = typer.Option(2, "--interval", "-i", help="Refresh interval in seconds"),
|
|
518
|
+
):
|
|
519
|
+
"""Watch task progress in real-time."""
|
|
520
|
+
# Handle partial task IDs
|
|
521
|
+
if len(task_id) < 36:
|
|
522
|
+
tasks = run_async(_list_tasks(None, 100))
|
|
523
|
+
matches = [t for t in tasks if t["task_id"].startswith(task_id)]
|
|
524
|
+
if len(matches) == 1:
|
|
525
|
+
task_id = matches[0]["task_id"]
|
|
526
|
+
elif len(matches) == 0:
|
|
527
|
+
console.print(f"[red]No task found starting with '{task_id}'[/red]")
|
|
528
|
+
return
|
|
529
|
+
else:
|
|
530
|
+
console.print(f"[yellow]Multiple tasks match. Use more characters.[/yellow]")
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
console.print(f"\n[bold]Watching task {task_id[:8]}...[/bold]")
|
|
534
|
+
console.print("[dim]Press Ctrl+C to stop watching[/dim]\n")
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
with Progress(
|
|
538
|
+
SpinnerColumn(),
|
|
539
|
+
TextColumn("[progress.description]{task.description}"),
|
|
540
|
+
BarColumn(),
|
|
541
|
+
TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
|
|
542
|
+
console=console,
|
|
543
|
+
) as progress:
|
|
544
|
+
progress_task = progress.add_task("Progress", total=100)
|
|
545
|
+
|
|
546
|
+
while True:
|
|
547
|
+
task = run_async(_get_task(task_id))
|
|
548
|
+
if not task:
|
|
549
|
+
break
|
|
550
|
+
|
|
551
|
+
status = task["status"]
|
|
552
|
+
percent = task["progress_percent"]
|
|
553
|
+
|
|
554
|
+
progress.update(
|
|
555
|
+
progress_task,
|
|
556
|
+
completed=percent,
|
|
557
|
+
description=f"{STATUS_EMOJI.get(status, '❓')} {task['task_name'][:40]}..."
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
# Stop watching if task is done
|
|
561
|
+
if status in ("completed", "failed", "cancelled"):
|
|
562
|
+
progress.update(progress_task, completed=100)
|
|
563
|
+
break
|
|
564
|
+
|
|
565
|
+
import time
|
|
566
|
+
time.sleep(interval)
|
|
567
|
+
|
|
568
|
+
# Show final status
|
|
569
|
+
task = run_async(_get_task(task_id))
|
|
570
|
+
if task:
|
|
571
|
+
console.print()
|
|
572
|
+
if task["status"] == "completed":
|
|
573
|
+
console.print(f"[green]✓ Task completed successfully![/green]")
|
|
574
|
+
elif task["status"] == "failed":
|
|
575
|
+
console.print(f"[red]✗ Task failed: {task.get('error_message', 'Unknown error')}[/red]")
|
|
576
|
+
elif task["status"] == "cancelled":
|
|
577
|
+
console.print(f"[yellow]⚠ Task was cancelled[/yellow]")
|
|
578
|
+
|
|
579
|
+
console.print(f"\n[dim]Use 'sudosu tasks status {task_id[:8]}' for details[/dim]")
|
|
580
|
+
|
|
581
|
+
except KeyboardInterrupt:
|
|
582
|
+
console.print("\n[dim]Stopped watching.[/dim]")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def handle_tasks_command(args: list[str]):
|
|
586
|
+
"""Handle the tasks command from main CLI."""
|
|
587
|
+
if not args:
|
|
588
|
+
# Default to list with explicit default values
|
|
589
|
+
tasks = run_async(_list_tasks(None, 10))
|
|
590
|
+
|
|
591
|
+
if not tasks:
|
|
592
|
+
console.print("\n[dim]No background tasks found.[/dim]\n")
|
|
593
|
+
return
|
|
594
|
+
|
|
595
|
+
table = Table(title="Background Tasks", show_header=True, header_style="bold cyan")
|
|
596
|
+
table.add_column("ID", style="dim", width=10)
|
|
597
|
+
table.add_column("Status", width=12)
|
|
598
|
+
table.add_column("Task Name", width=35)
|
|
599
|
+
table.add_column("Progress", width=12)
|
|
600
|
+
table.add_column("Created", width=16)
|
|
601
|
+
|
|
602
|
+
for task in tasks:
|
|
603
|
+
status_emoji = STATUS_EMOJI.get(task["status"], "❓")
|
|
604
|
+
status_text = f"{status_emoji} {task['status']}"
|
|
605
|
+
|
|
606
|
+
# Format progress
|
|
607
|
+
if task["status"] == "running":
|
|
608
|
+
progress = f"{task['progress_percent']:.0f}%"
|
|
609
|
+
elif task["status"] == "completed":
|
|
610
|
+
progress = f"✓ {task['completed_items']}/{task['total_items']}"
|
|
611
|
+
elif task["status"] == "failed":
|
|
612
|
+
progress = f"✗ {task['completed_items']}/{task['total_items']}"
|
|
613
|
+
else:
|
|
614
|
+
progress = "-"
|
|
615
|
+
|
|
616
|
+
# Truncate task name
|
|
617
|
+
task_name = task["task_name"]
|
|
618
|
+
if len(task_name) > 33:
|
|
619
|
+
task_name = task_name[:30] + "..."
|
|
620
|
+
|
|
621
|
+
table.add_row(
|
|
622
|
+
task["task_id"][:8] + "...",
|
|
623
|
+
status_text,
|
|
624
|
+
task_name,
|
|
625
|
+
progress,
|
|
626
|
+
format_timestamp(task["created_at"]),
|
|
627
|
+
)
|
|
628
|
+
|
|
629
|
+
console.print()
|
|
630
|
+
console.print(table)
|
|
631
|
+
console.print()
|
|
632
|
+
console.print("[dim]Use '/tasks status <task_id>' for details[/dim]")
|
|
633
|
+
else:
|
|
634
|
+
# Pass args to typer app for subcommands
|
|
635
|
+
app(args)
|