Files
echo-core/src/approved_tasks_cli.py
Marius Mutu 5e930ade02 feat(dashboard): unified workspace hub — cookie auth, 9-state projects, planning chat
Merges workspace.html + ralph.html into a single unified project hub with:
- Cookie-based auth (DASHBOARD_TOKEN, HttpOnly, SameSite=Strict)
- 9-state project badge system (running-ralph/manual, planning, approved,
  pending, blocked, failed, complete, idle) with BUTTONS_FOR_STATE matrix
- SSE realtime + polling fallback, version-based optimistic concurrency (If-Match)
- Planning chat modal (phase stepper, markdown bubbles, 50s+ wait state, auto-resume)
- Propose modal (Variant B: inline Plan-with-Echo checkbox)
- 5-type toast taxonomy (success/info/warning/busy/error, 3px colored left-bar)
- Inter font self-hosted + shared tokens.css design system + DESIGN.md
- src/jsonlock.py (flock helper, sidecar .lock for stable inode)
- src/approved_tasks_cli.py (shell-safe wrapper for cron/ralph.sh)
- 55 new tests (T#1–T#30) + real jsonlock bug fix caught by T#16/T#28
- No emoji anywhere (enforced by test_dashboard_no_emoji.py)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 07:26:19 +00:00

281 lines
8.9 KiB
Python

"""CLI wrapper for atomic mutations of `approved-tasks.json`.
Shell scripts (ralph.sh) and cron-job prompts cannot import
`src.jsonlock` directly. This module is the bridge: every subcommand
serialises its mutation through ``write_locked`` so external writers
honour the same flock invariant as the in-process code in
``src/router.py`` / ``src/planning_session.py``.
Run via:
python3 -m src.approved_tasks_cli <subcommand> [args]
Subcommands:
set-status --slug SLUG --status STATUS
set-field --slug SLUG --key KEY --value VALUE [--int|--null|--now|--json]
add-project --slug SLUG --description DESC [--status STATUS]
mark-running --slug SLUG --pid PID
mark-failed --slug SLUG [--error MSG]
show [--slug SLUG] # read-only inspection
All mutators bump ``last_updated`` to the current UTC ISO timestamp.
Exit codes:
0 success
1 bad usage / invalid argument
2 slug not found
3 lock timeout / IO error
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
# Make `from src.jsonlock import ...` work whether invoked as
# `python -m src.approved_tasks_cli` (sys.path already correct) or as
# `python3 src/approved_tasks_cli.py` (need to prepend project root).
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
if str(_PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(_PROJECT_ROOT))
from src.jsonlock import read_locked, write_locked, LockTimeoutError # noqa: E402
APPROVED_TASKS_FILE = _PROJECT_ROOT / "approved-tasks.json"
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _bump_timestamp(data: dict) -> None:
data["last_updated"] = _now_iso()
def _find_project(data: dict, slug: str) -> dict | None:
for p in data.get("projects", []):
if p.get("name", "").lower() == slug.lower():
return p
return None
def _coerce_value(value: str, *, as_int: bool, as_null: bool, as_now: bool, as_json: bool):
if as_null:
return None
if as_now:
return _now_iso()
if as_int:
try:
return int(value)
except ValueError as exc:
raise SystemExit(f"value '{value}' is not a valid int: {exc}") from exc
if as_json:
try:
return json.loads(value)
except json.JSONDecodeError as exc:
raise SystemExit(f"value is not valid JSON: {exc}") from exc
return value
# ---- subcommand implementations -------------------------------------------
def cmd_set_status(args) -> int:
def mutator(data: dict) -> dict:
proj = _find_project(data, args.slug)
if proj is None:
raise KeyError(args.slug)
proj["status"] = args.status
_bump_timestamp(data)
return data
try:
write_locked(str(APPROVED_TASKS_FILE), mutator)
except KeyError:
print(f"slug '{args.slug}' not found", file=sys.stderr)
return 2
print(f"set status of '{args.slug}' = '{args.status}'")
return 0
def cmd_set_field(args) -> int:
value = _coerce_value(
args.value or "",
as_int=args.int,
as_null=args.null,
as_now=args.now,
as_json=args.json_value,
)
def mutator(data: dict) -> dict:
proj = _find_project(data, args.slug)
if proj is None:
raise KeyError(args.slug)
proj[args.key] = value
_bump_timestamp(data)
return data
try:
write_locked(str(APPROVED_TASKS_FILE), mutator)
except KeyError:
print(f"slug '{args.slug}' not found", file=sys.stderr)
return 2
print(f"set {args.slug}.{args.key} = {value!r}")
return 0
def cmd_add_project(args) -> int:
def mutator(data: dict) -> dict:
data.setdefault("projects", [])
if _find_project(data, args.slug) is not None:
raise FileExistsError(args.slug)
entry = {
"name": args.slug,
"description": args.description,
"status": args.status,
"planning_session_id": None,
"final_plan_path": None,
"proposed_at": _now_iso(),
"approved_at": None,
"started_at": None,
"pid": None,
}
data["projects"].append(entry)
_bump_timestamp(data)
return data
try:
write_locked(str(APPROVED_TASKS_FILE), mutator)
except FileExistsError:
print(f"slug '{args.slug}' already exists — refusing to overwrite", file=sys.stderr)
return 1
print(f"added project '{args.slug}' (status={args.status})")
return 0
def cmd_mark_running(args) -> int:
"""Convenience: status=running, started_at=now, pid=<PID> in ONE locked write."""
def mutator(data: dict) -> dict:
proj = _find_project(data, args.slug)
if proj is None:
raise KeyError(args.slug)
proj["status"] = "running"
proj["started_at"] = _now_iso()
proj["pid"] = args.pid
_bump_timestamp(data)
return data
try:
write_locked(str(APPROVED_TASKS_FILE), mutator)
except KeyError:
print(f"slug '{args.slug}' not found", file=sys.stderr)
return 2
print(f"marked '{args.slug}' running (pid={args.pid})")
return 0
def cmd_mark_failed(args) -> int:
def mutator(data: dict) -> dict:
proj = _find_project(data, args.slug)
if proj is None:
raise KeyError(args.slug)
proj["status"] = "failed"
if args.error:
proj["error"] = args.error
_bump_timestamp(data)
return data
try:
write_locked(str(APPROVED_TASKS_FILE), mutator)
except KeyError:
print(f"slug '{args.slug}' not found", file=sys.stderr)
return 2
print(f"marked '{args.slug}' failed")
return 0
def cmd_show(args) -> int:
try:
data = read_locked(str(APPROVED_TASKS_FILE))
except FileNotFoundError:
print(f"file not found: {APPROVED_TASKS_FILE}", file=sys.stderr)
return 3
if args.slug:
proj = _find_project(data, args.slug)
if proj is None:
print(f"slug '{args.slug}' not found", file=sys.stderr)
return 2
print(json.dumps(proj, indent=2, ensure_ascii=False))
else:
print(json.dumps(data, indent=2, ensure_ascii=False))
return 0
# ---- argparse setup -------------------------------------------------------
def _build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
prog="approved-tasks",
description="Atomic CLI for approved-tasks.json (uses src.jsonlock.write_locked).",
)
sub = p.add_subparsers(dest="command", required=True)
sp = sub.add_parser("set-status", help="Set the status field of a project.")
sp.add_argument("--slug", required=True)
sp.add_argument("--status", required=True)
sp.set_defaults(func=cmd_set_status)
sp = sub.add_parser("set-field", help="Set an arbitrary field on a project.")
sp.add_argument("--slug", required=True)
sp.add_argument("--key", required=True)
sp.add_argument("--value", default="", help="String value (or use --null/--now).")
g = sp.add_mutually_exclusive_group()
g.add_argument("--int", action="store_true", help="Coerce --value to int.")
g.add_argument("--null", action="store_true", help="Set value to JSON null.")
g.add_argument("--now", action="store_true", help="Set value to current UTC ISO timestamp.")
g.add_argument("--json-value", action="store_true", dest="json_value",
help="Parse --value as JSON.")
sp.set_defaults(func=cmd_set_field)
sp = sub.add_parser("add-project", help="Append a new project entry.")
sp.add_argument("--slug", required=True)
sp.add_argument("--description", required=True)
sp.add_argument("--status", default="pending")
sp.set_defaults(func=cmd_add_project)
sp = sub.add_parser("mark-running", help="Atomic: status=running, started_at=now, pid=<PID>.")
sp.add_argument("--slug", required=True)
sp.add_argument("--pid", type=int, required=True)
sp.set_defaults(func=cmd_mark_running)
sp = sub.add_parser("mark-failed", help="Set status=failed (and optionally an error message).")
sp.add_argument("--slug", required=True)
sp.add_argument("--error", default=None)
sp.set_defaults(func=cmd_mark_failed)
sp = sub.add_parser("show", help="Print approved-tasks.json (or one project) to stdout.")
sp.add_argument("--slug", default=None)
sp.set_defaults(func=cmd_show)
return p
def main(argv: list[str] | None = None) -> int:
parser = _build_parser()
args = parser.parse_args(argv)
try:
return args.func(args)
except LockTimeoutError as exc:
print(f"lock timeout: {exc}", file=sys.stderr)
return 3
except OSError as exc:
print(f"io error: {exc}", file=sys.stderr)
return 3
if __name__ == "__main__":
sys.exit(main())