Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions solvent/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
(none) run the batch demo / interactive session
status live summary: balance, jobs, API key presence; --watch to auto-refresh
upgrade check for newer version on PyPI; --check exits 1 if outdated
jobs list/show/retry/cancel jobs (jobs --help for sub-commands)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Register the advertised jobs retry command

solvent jobs retry <id> is advertised here and in job_cmd.py's usage text, but the jobs parser only registers list, show, events, and cancel, with no retry dispatch branch; running it exits with invalid choice: 'retry'. This leaves the new dedicated jobs CLI unable to perform one of its promised management actions even though StageRunner.retry_job already exists.

Useful? React with 👍 / 👎.

serve webhooks + job API + hosted briefs
worker resume incomplete jobs, process the queue
telegram long-poll the Telegram bot
Expand Down Expand Up @@ -46,6 +47,10 @@ def main():
sys.argv.pop(1)
from .status import main as status_main
status_main()
elif len(sys.argv) > 1 and sys.argv[1] == "jobs":
sys.argv.pop(1)
from .job_cmd import main as jobs_main
jobs_main()
elif len(sys.argv) > 1 and sys.argv[1] == "upgrade":
sys.argv.pop(1)
from .upgrade import main as upgrade_main
Expand Down
251 changes: 251 additions & 0 deletions solvent/job_cmd.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,251 @@
"""
job_cmd.py — CLI for inspecting and managing SOLVENT jobs.

Usage:
solvent jobs list recent jobs (last 20)
solvent jobs list same as above
solvent jobs list --status=running
solvent jobs list --status=failed --limit=50
solvent jobs show <id> full detail for one job
solvent jobs events <id> event log for one job
solvent jobs retry <id> re-run a stuck/failed job
solvent jobs cancel <id> mark a job as cancelled
solvent jobs --json machine-readable list output
"""

from __future__ import annotations

import json
import sys
import time
from datetime import datetime
from typing import Optional


_STATUS_EMOJI = {
"awaiting_payment": "⏳",
"paid_pending_fulfill": "💳",
"in_progress": "▶ ",
"completed": "✓ ",
"failed": "✗ ",
"cancelled": "⊘ ",
}

_ALL_STATUSES = list(_STATUS_EMOJI.keys())


def _fmt_ts(ts) -> str:
if not ts:
return "—"
try:
return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M")
except (ValueError, OSError, TypeError):
return str(ts)


def _ago(ts) -> str:
if not ts:
return ""
try:
delta = time.time() - float(ts)
except (ValueError, TypeError):
return ""
if delta < 60:
return f"{int(delta)}s"
if delta < 3600:
return f"{int(delta/60)}m"
if delta < 86400:
return f"{int(delta/3600)}h"
return f"{int(delta/86400)}d"


def _fmt_cents(cents) -> str:
if cents is None:
return "—"
return f"${int(cents)/100:,.2f}"


def _status_label(status: str) -> str:
emoji = _STATUS_EMOJI.get(status, "? ")
return f"{emoji} {status}"


def _col(s: str, width: int) -> str:
s = str(s or "")
if len(s) > width:
return s[: width - 1] + "…"
return s.ljust(width)


# ---------------------------------------------------------------------------
# Sub-commands
# ---------------------------------------------------------------------------

def cmd_list(treasury, *, status_filter: Optional[str] = None, limit: int = 20, as_json: bool = False):
jobs = treasury.list_jobs()
if status_filter:
jobs = [j for j in jobs if j.get("status") == status_filter]
jobs = jobs[-limit:]

if as_json:
print(json.dumps(jobs, indent=2, default=str))
return

if not jobs:
msg = "No jobs"
if status_filter:
msg += f" with status '{status_filter}'"
print(msg)
return

header = f"{'ID':18} {'STATUS':22} {'TOPIC':42} {'BUDGET':8} {'AGO':5}"
print(header)
print("─" * len(header))

for j in reversed(jobs):
jid = j.get("id", "")[:17]
status = j.get("status", "")
emoji = _STATUS_EMOJI.get(status, "? ")
topic = (j.get("topic") or "")[:41]
budget = _fmt_cents(j.get("budget_cents"))
ago = _ago(j.get("updated_at") or j.get("created_at"))
print(
f"{_col(jid, 18)} {emoji}{_col(status, 20)} {_col(topic, 42)} {_col(budget, 8)} {ago}"
)


def cmd_show(treasury, job_id: str, *, as_json: bool = False):
job = treasury.get_job(job_id)
if not job:
print(f"Job not found: {job_id}", file=sys.stderr)
sys.exit(1)

try:
pnl = treasury.job_pnl_cents(job_id)
except Exception:
pnl = None

try:
metrics = treasury.get_metrics(job_id) or {}
except Exception:
metrics = {}

if as_json:
out = dict(job)
out["pnl_cents"] = pnl
out["metrics"] = metrics
print(json.dumps(out, indent=2, default=str))
return

status = job.get("status", "")
print(f"Job: {job_id}")
print(f"Status: {_status_label(status)}")
print(f"Topic: {job.get('topic') or '—'}")
print(f"Customer: {job.get('customer_email') or '—'}")
print(f"Budget: {_fmt_cents(job.get('budget_cents'))}")
if pnl is not None:
print(f"P&L: {_fmt_cents(pnl)}")
print(f"Created: {_fmt_ts(job.get('created_at'))}")
print(f"Updated: {_fmt_ts(job.get('updated_at'))}")

if metrics:
print("\nMetrics:")
for k, v in metrics.items():
if k == "job_id":
continue
print(f" {k}: {v}")


def cmd_events(treasury, job_id: str, *, limit: int = 20, as_json: bool = False):
job = treasury.get_job(job_id)
if not job:
print(f"Job not found: {job_id}", file=sys.stderr)
sys.exit(1)

events = treasury.list_events(job_id=job_id, limit=limit)

if as_json:
print(json.dumps(events, indent=2, default=str))
return

if not events:
print(f"No events for job {job_id}")
return

print(f"Events for {job_id}:")
print(f" {'TIME':17} {'STAGE':20}")
print(f" {'─'*17} {'─'*20}")
for ev in events:
ts = _fmt_ts(ev.get("ts"))
stage = ev.get("stage", "")
print(f" {ts:17} {stage}")


def cmd_cancel(treasury, job_id: str):
job = treasury.get_job(job_id)
if not job:
print(f"Job not found: {job_id}", file=sys.stderr)
sys.exit(1)

current = job.get("status", "")
if current in ("completed", "cancelled"):
print(f"Job {job_id} is already {current} — nothing to do.")
return

treasury.upsert_job(job_id, "cancelled")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block cancelling paid jobs without refund

When this runs for a job in paid_pending_fulfill or in_progress, it overwrites the status with cancelled; I checked solvent/queue.py and the worker only claims awaiting_payment, in_progress, paid_pending_fulfill, and pending_quote, so a paid job can be removed from processing without delivery or the existing refund/failure path. Please disallow cancellation once revenue/payment exists, or route it through refund handling.

Useful? React with 👍 / 👎.

treasury.record_event(job_id, "cancelled", {"reason": "manual-cancel"})
print(f"Job {job_id} cancelled (was: {current})")


# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------

def main():
import argparse

p = argparse.ArgumentParser(
prog="solvent jobs",
description="Inspect and manage SOLVENT jobs.",
)
p.add_argument("--json", action="store_true", dest="as_json", help="output as JSON")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept --json after each jobs subcommand

Because --json is registered only on the parent parser, argparse requires it before the subcommand; the advertised/common forms solvent jobs list --json, solvent jobs show <id> --json, and solvent jobs events <id> --json exit with an unrecognized-arguments error. Add the flag to the subparsers or otherwise parse it so the documented scripting form works.

Useful? React with 👍 / 👎.


sub = p.add_subparsers(dest="cmd")

list_p = sub.add_parser("list", help="list recent jobs")
list_p.add_argument("--status", choices=_ALL_STATUSES, help="filter by status")
list_p.add_argument("--limit", type=int, default=20)

show_p = sub.add_parser("show", help="full detail for one job")
show_p.add_argument("job_id")

ev_p = sub.add_parser("events", help="event log for one job")
ev_p.add_argument("job_id")
ev_p.add_argument("--limit", type=int, default=20)

cancel_p = sub.add_parser("cancel", help="cancel a job")
cancel_p.add_argument("job_id")

args = p.parse_args()
cmd = args.cmd or "list"

from .treasury import Treasury
treasury = Treasury()

if cmd == "list":
cmd_list(
treasury,
status_filter=getattr(args, "status", None),
limit=getattr(args, "limit", 20),
as_json=args.as_json,
)
elif cmd == "show":
cmd_show(treasury, args.job_id, as_json=args.as_json)
elif cmd == "events":
cmd_events(treasury, args.job_id, limit=args.limit, as_json=args.as_json)
elif cmd == "cancel":
cmd_cancel(treasury, args.job_id)


if __name__ == "__main__":
main()
Loading
Loading