@grifhinz/logics-manager 2.5.2 → 2.7.0

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.
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ import hashlib
4
5
  import json
5
6
  import mimetypes
6
7
  import os
@@ -20,6 +21,7 @@ from typing import Any
20
21
  from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
21
22
 
22
23
  from .audit import audit_payload
24
+ from .bootstrap import bootstrap_payload
23
25
  from .config import find_repo_root
24
26
  from .lint import lint_payload
25
27
  from .update_check import get_update_info
@@ -342,18 +344,28 @@ def viewer_data_payload(
342
344
  selected_id: str | None = None,
343
345
  *,
344
346
  auto_refresh_interval_seconds: int = 15,
347
+ projects: list[dict[str, Any]] | None = None,
345
348
  ) -> dict[str, Any]:
349
+ capabilities = viewer_project_capabilities(repo_root)
350
+ active_root = repo_root.resolve()
351
+ has_logics = capabilities["logics"]["available"] is True
346
352
  return {
347
- "root": str(repo_root.resolve()),
348
- "repoName": repo_root.resolve().name,
353
+ "root": str(active_root),
354
+ "repoName": active_root.name,
355
+ "repository": {
356
+ "root": str(active_root),
357
+ "githubUrl": github_repo_url(repo_root),
358
+ },
359
+ "capabilities": capabilities,
360
+ "projects": projects if projects is not None else viewer_project_registry(repo_root),
349
361
  "autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
350
362
  "items": collect_viewer_items(repo_root),
351
363
  "updateInfo": get_update_info(_current_version()).to_payload(),
352
364
  "selectedId": selected_id,
353
365
  "changedPaths": [],
354
366
  "canResetProjectRoot": False,
355
- "canBootstrapLogics": False,
356
- "bootstrapLogicsTitle": "Local viewer is read-only. Use the CLI to bootstrap Logics.",
367
+ "canBootstrapLogics": not has_logics,
368
+ "bootstrapLogicsTitle": "Bootstrap Logics in this project." if not has_logics else "Logics is already bootstrapped.",
357
369
  "canLaunchCodex": False,
358
370
  "canLaunchClaude": False,
359
371
  "canRepairLogicsKit": False,
@@ -362,6 +374,155 @@ def viewer_data_payload(
362
374
  }
363
375
 
364
376
 
377
+ def _viewer_project_id(repo_root: Path) -> str:
378
+ normalized = str(repo_root.resolve())
379
+ return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
380
+
381
+
382
+ def _looks_like_viewer_project(path: Path) -> bool:
383
+ if not path.is_dir():
384
+ return False
385
+ return any((path / marker).exists() for marker in ("logics", ".git", "package.json", "pyproject.toml", "logics.yaml"))
386
+
387
+
388
+ def discover_viewer_project_roots(repo_root: Path, *, max_projects: int = 40) -> list[Path]:
389
+ active = repo_root.resolve()
390
+ candidates: list[Path] = [active]
391
+ parent = active.parent
392
+ try:
393
+ siblings = sorted(parent.iterdir(), key=lambda path: path.name.lower())
394
+ except OSError:
395
+ siblings = []
396
+ for sibling in siblings:
397
+ try:
398
+ resolved = sibling.resolve()
399
+ except OSError:
400
+ continue
401
+ if resolved == active or not _looks_like_viewer_project(resolved):
402
+ continue
403
+ candidates.append(resolved)
404
+ if len(candidates) >= max_projects:
405
+ break
406
+
407
+ unique: dict[str, Path] = {}
408
+ for candidate in candidates:
409
+ unique[str(candidate)] = candidate
410
+ return list(unique.values())
411
+
412
+
413
+ def viewer_project_entry(repo_root: Path, *, active_root: Path | None = None) -> dict[str, Any]:
414
+ root = repo_root.resolve()
415
+ active = active_root.resolve() if active_root else root
416
+ has_logics = (root / "logics").is_dir()
417
+ available = root.is_dir()
418
+ return {
419
+ "id": _viewer_project_id(root),
420
+ "name": root.name,
421
+ "root": str(root),
422
+ "active": root == active,
423
+ "available": available,
424
+ "hasLogics": has_logics,
425
+ "message": "Logics corpus found." if has_logics else "No Logics corpus found.",
426
+ }
427
+
428
+
429
+ def viewer_project_registry(repo_root: Path, *, project_roots: list[Path] | None = None) -> list[dict[str, Any]]:
430
+ active = repo_root.resolve()
431
+ roots = project_roots if project_roots is not None else discover_viewer_project_roots(active)
432
+ return [viewer_project_entry(root, active_root=active) for root in roots]
433
+
434
+
435
+ def _viewer_capability(state: str, *, available: bool, message: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
436
+ payload: dict[str, Any] = {
437
+ "state": state,
438
+ "available": available,
439
+ "message": message,
440
+ }
441
+ if detail:
442
+ payload["detail"] = detail
443
+ return payload
444
+
445
+
446
+ def _git_is_repository(repo_root: Path, *, runner: Any | None = None) -> bool | None:
447
+ try:
448
+ result = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
449
+ except (OSError, subprocess.SubprocessError):
450
+ return None
451
+ if result.returncode != 0:
452
+ return False
453
+ return result.stdout.strip().lower() == "true"
454
+
455
+
456
+ def viewer_project_capabilities(
457
+ repo_root: Path,
458
+ *,
459
+ git_runner: Any | None = None,
460
+ which: Any | None = None,
461
+ ) -> dict[str, Any]:
462
+ which_command = which or shutil.which
463
+ logics_dir = repo_root / "logics"
464
+ has_logics = logics_dir.is_dir()
465
+ git_path = which_command("git")
466
+ cdx_path = which_command("cdx")
467
+
468
+ if has_logics:
469
+ logics = _viewer_capability("ready", available=True, message="Logics corpus found.")
470
+ else:
471
+ logics = _viewer_capability("missing", available=False, message="No Logics corpus found.")
472
+
473
+ if not git_path:
474
+ git = _viewer_capability("unavailable", available=False, message="Git executable is not available.")
475
+ github_url = ""
476
+ has_workflows = False
477
+ else:
478
+ is_repo = _git_is_repository(repo_root, runner=git_runner)
479
+ if is_repo is True:
480
+ git = _viewer_capability("ready", available=True, message="Git repository detected.")
481
+ github_url = github_repo_url(repo_root, runner=git_runner, which=which_command)
482
+ has_workflows = _has_github_actions_workflows(repo_root)
483
+ elif is_repo is False:
484
+ git = _viewer_capability("missing", available=False, message="Project is not a Git repository.")
485
+ github_url = ""
486
+ has_workflows = False
487
+ else:
488
+ git = _viewer_capability("error", available=False, message="Unable to inspect Git repository state.")
489
+ github_url = ""
490
+ has_workflows = False
491
+
492
+ if not github_url:
493
+ ci = _viewer_capability("hidden", available=False, message="No GitHub remote detected for this project.")
494
+ elif not has_workflows:
495
+ ci = _viewer_capability("hidden", available=False, message="No GitHub Actions workflows detected for this project.")
496
+ elif not which_command("gh"):
497
+ ci = _viewer_capability("unavailable", available=False, message="GitHub CLI is not available.")
498
+ else:
499
+ ci = _viewer_capability(
500
+ "ready",
501
+ available=True,
502
+ message="GitHub Actions can be inspected.",
503
+ detail={"githubUrl": github_url},
504
+ )
505
+
506
+ if cdx_path:
507
+ cdx = _viewer_capability("ready", available=True, message="CDX executable detected.")
508
+ cdx_runs = _viewer_capability(
509
+ "unsupported",
510
+ available=False,
511
+ message="CDX assistant run registry is not available yet.",
512
+ )
513
+ else:
514
+ cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
515
+ cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
516
+
517
+ return {
518
+ "logics": logics,
519
+ "git": git,
520
+ "ci": ci,
521
+ "cdx": cdx,
522
+ "cdxRuns": cdx_runs,
523
+ }
524
+
525
+
365
526
  def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
366
527
  normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
367
528
  return {
@@ -392,6 +553,17 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
392
553
  }
393
554
 
394
555
 
556
+ def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
557
+ root = repo_root.resolve()
558
+ command = _system_editor_command(root)
559
+ runner = launcher or subprocess.Popen
560
+ runner(command)
561
+ return {
562
+ "path": str(root),
563
+ "command": command[0],
564
+ }
565
+
566
+
395
567
  def _system_editor_command(path: Path) -> list[str]:
396
568
  if sys.platform == "darwin":
397
569
  return ["open", str(path)]
@@ -412,6 +584,12 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
412
584
  return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
413
585
 
414
586
 
587
+ def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
588
+ command = ["gh", *args]
589
+ gh_runner = runner or subprocess.run
590
+ return gh_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=8)
591
+
592
+
415
593
  def _logics_doc_type(rel_path: str) -> str:
416
594
  normalized = rel_path.replace("\\", "/").lstrip("/")
417
595
  for family in DOC_FAMILIES:
@@ -427,6 +605,62 @@ def _sanitize_git_ref(value: str) -> str:
427
605
  return ref[:200]
428
606
 
429
607
 
608
+ def _github_web_url_from_remote(value: str) -> str:
609
+ remote = value.strip()
610
+ if not remote:
611
+ return ""
612
+ remote = re.sub(r"^git\+", "", remote)
613
+ match = re.match(r"^(?:https://|http://)(?:[^/@\s]+@)?github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", remote)
614
+ if not match:
615
+ match = re.match(r"^(?:ssh://)?git@github\.com[:/]+([^/\s]+)/([^/\s]+?)(?:\.git)?/?$", remote)
616
+ if not match:
617
+ return ""
618
+ owner, repo = match.groups()
619
+ if not owner or not repo:
620
+ return ""
621
+ return f"https://github.com/{owner}/{repo}"
622
+
623
+
624
+ def _github_owner_repo_from_web_url(value: str) -> tuple[str, str] | None:
625
+ match = re.match(r"^https://github\.com/([^/\s]+)/([^/\s]+?)/?$", value.strip())
626
+ if not match:
627
+ return None
628
+ owner, repo = match.groups()
629
+ return owner, repo
630
+
631
+
632
+ def github_repo_url(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
633
+ git_which = which or shutil.which
634
+ if not git_which("git"):
635
+ return ""
636
+ try:
637
+ remotes = _run_read_only_git(repo_root, ["remote", "-v"], runner=runner)
638
+ except (OSError, subprocess.SubprocessError):
639
+ return ""
640
+ if remotes.returncode != 0:
641
+ return ""
642
+
643
+ candidates: list[tuple[int, str]] = []
644
+ for line in remotes.stdout.splitlines():
645
+ parts = line.split()
646
+ if len(parts) < 2:
647
+ continue
648
+ name, url = parts[0], parts[1]
649
+ web_url = _github_web_url_from_remote(url)
650
+ if web_url:
651
+ candidates.append((0 if name == "origin" else 1, web_url))
652
+ if not candidates:
653
+ return ""
654
+ return sorted(candidates, key=lambda entry: entry[0])[0][1]
655
+
656
+
657
+ def _has_github_actions_workflows(repo_root: Path) -> bool:
658
+ workflows_dir = repo_root / ".github" / "workflows"
659
+ if not workflows_dir.is_dir():
660
+ return False
661
+ return any(path.is_file() and path.suffix.lower() in {".yml", ".yaml"} for path in workflows_dir.iterdir())
662
+
663
+
430
664
  def _classify_porcelain_entry(line: str) -> tuple[str, dict[str, str]] | None:
431
665
  if not line or line.startswith("## "):
432
666
  return None
@@ -579,7 +813,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
579
813
  commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
580
814
  recent_commits = _run_read_only_git(
581
815
  repo_root,
582
- ["log", "-8", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
816
+ ["log", "-50", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
583
817
  runner=runner,
584
818
  )
585
819
  unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
@@ -678,6 +912,164 @@ def git_diff_payload(
678
912
  }
679
913
 
680
914
 
915
+ def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
916
+ context = {"branch": "", "headSha": "", "subject": "", "author": ""}
917
+ commands = {
918
+ "branch": ["rev-parse", "--abbrev-ref", "HEAD"],
919
+ "headSha": ["rev-parse", "HEAD"],
920
+ "subject": ["log", "-1", "--pretty=format:%s"],
921
+ "author": ["log", "-1", "--pretty=format:%an"],
922
+ }
923
+ for key, args in commands.items():
924
+ try:
925
+ result = _run_read_only_git(repo_root, args, runner=runner)
926
+ except (OSError, subprocess.SubprocessError):
927
+ continue
928
+ if result.returncode == 0:
929
+ context[key] = result.stdout.strip()[:240]
930
+ if context["branch"] == "HEAD":
931
+ context["branch"] = ""
932
+ return context
933
+
934
+
935
+ def _ci_badge_state(status: str, conclusion: str) -> str:
936
+ normalized_status = status.strip().lower()
937
+ normalized_conclusion = conclusion.strip().lower()
938
+ if normalized_status in {"queued", "in_progress", "waiting", "requested", "pending"}:
939
+ return "running" if normalized_status == "in_progress" else "queued"
940
+ if normalized_conclusion == "success":
941
+ return "passing"
942
+ if normalized_conclusion in {"failure", "timed_out", "action_required"}:
943
+ return "failing"
944
+ if normalized_conclusion == "cancelled":
945
+ return "cancelled"
946
+ return "unknown"
947
+
948
+
949
+ def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
950
+ status = str(run.get("status") or "")
951
+ conclusion = str(run.get("conclusion") or "")
952
+ commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
953
+ author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
954
+ return {
955
+ "id": run.get("id"),
956
+ "name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
957
+ "workflowName": str(run.get("name") or "GitHub Actions"),
958
+ "status": status,
959
+ "conclusion": conclusion,
960
+ "badgeState": _ci_badge_state(status, conclusion),
961
+ "branch": str(run.get("head_branch") or ""),
962
+ "headSha": str(run.get("head_sha") or ""),
963
+ "event": str(run.get("event") or ""),
964
+ "htmlUrl": str(run.get("html_url") or ""),
965
+ "createdAt": str(run.get("created_at") or ""),
966
+ "updatedAt": str(run.get("updated_at") or ""),
967
+ "runStartedAt": str(run.get("run_started_at") or ""),
968
+ "commitMessage": str(commit.get("message") or run.get("display_title") or "").splitlines()[0][:240],
969
+ "author": str(author.get("name") or ""),
970
+ "matchSource": match_source,
971
+ }
972
+
973
+
974
+ def _parse_github_actions_jobs(output: str) -> list[dict[str, str]]:
975
+ try:
976
+ parsed = json.loads(output or "{}")
977
+ except json.JSONDecodeError:
978
+ return []
979
+ if not isinstance(parsed, dict):
980
+ return []
981
+ jobs = parsed.get("jobs")
982
+ if not isinstance(jobs, list):
983
+ return []
984
+ rows: list[dict[str, str]] = []
985
+ for job in jobs[:30]:
986
+ if not isinstance(job, dict):
987
+ continue
988
+ rows.append(
989
+ {
990
+ "name": str(job.get("name") or "Job"),
991
+ "status": str(job.get("status") or ""),
992
+ "conclusion": str(job.get("conclusion") or ""),
993
+ "htmlUrl": str(job.get("html_url") or ""),
994
+ "startedAt": str(job.get("started_at") or ""),
995
+ "completedAt": str(job.get("completed_at") or ""),
996
+ }
997
+ )
998
+ return rows
999
+
1000
+
1001
+ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1002
+ git_which = which or shutil.which
1003
+ github_url = github_repo_url(repo_root, runner=git_runner, which=git_which)
1004
+ if not github_url:
1005
+ return {"state": "hidden", "visible": False, "message": "No GitHub remote detected."}
1006
+ owner_repo = _github_owner_repo_from_web_url(github_url)
1007
+ if not owner_repo:
1008
+ return {"state": "hidden", "visible": False, "message": "GitHub remote could not be parsed."}
1009
+ if not _has_github_actions_workflows(repo_root):
1010
+ return {"state": "hidden", "visible": False, "message": "No GitHub Actions workflows detected."}
1011
+ if not git_which("gh"):
1012
+ return {
1013
+ "state": "unavailable",
1014
+ "visible": True,
1015
+ "message": "GitHub CLI is not available on PATH.",
1016
+ "repositoryUrl": github_url,
1017
+ "badgeState": "unavailable",
1018
+ }
1019
+
1020
+ owner, repo = owner_repo
1021
+ context = _current_git_ci_context(repo_root, runner=git_runner)
1022
+ branch = context.get("branch", "")
1023
+ head_sha = context.get("headSha", "")
1024
+ endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=10"
1025
+ if branch:
1026
+ endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
1027
+ try:
1028
+ runs_result = _run_read_only_gh(repo_root, ["api", endpoint], runner=gh_runner)
1029
+ except subprocess.TimeoutExpired:
1030
+ return {"state": "timeout", "visible": True, "message": "GitHub Actions status timed out.", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
1031
+ except (OSError, subprocess.SubprocessError) as exc:
1032
+ return {"state": "error", "visible": True, "message": f"Unable to collect GitHub Actions status: {exc}", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
1033
+ if runs_result.returncode != 0:
1034
+ message = (runs_result.stderr or runs_result.stdout or "GitHub Actions status failed.").strip().splitlines()[0]
1035
+ return {"state": "unavailable", "visible": True, "message": message, "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
1036
+
1037
+ try:
1038
+ parsed = json.loads(runs_result.stdout or "{}")
1039
+ except json.JSONDecodeError:
1040
+ return {"state": "invalid-json", "visible": True, "message": "GitHub Actions status returned invalid JSON.", "repositoryUrl": github_url, **context, "badgeState": "unavailable"}
1041
+ workflow_runs = parsed.get("workflow_runs") if isinstance(parsed, dict) else None
1042
+ runs = [run for run in workflow_runs if isinstance(run, dict)] if isinstance(workflow_runs, list) else []
1043
+ if not runs:
1044
+ return {"state": "ok", "visible": True, "message": "No GitHub Actions runs found for the current branch.", "repositoryUrl": github_url, **context, "badgeState": "unknown", "run": None, "jobs": []}
1045
+
1046
+ selected = next((run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha), None)
1047
+ match_source = "head" if selected else "branch-latest"
1048
+ if selected is None:
1049
+ selected = runs[0]
1050
+ run_payload = _parse_github_actions_run(selected, match_source=match_source)
1051
+ jobs: list[dict[str, str]] = []
1052
+ run_id = run_payload.get("id")
1053
+ if run_id:
1054
+ try:
1055
+ jobs_result = _run_read_only_gh(repo_root, ["api", f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs?per_page=100"], runner=gh_runner)
1056
+ except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
1057
+ jobs_result = None
1058
+ if jobs_result is not None and jobs_result.returncode == 0:
1059
+ jobs = _parse_github_actions_jobs(jobs_result.stdout)
1060
+
1061
+ return {
1062
+ "state": "ok",
1063
+ "visible": True,
1064
+ "message": "",
1065
+ "repositoryUrl": github_url,
1066
+ **context,
1067
+ "badgeState": run_payload["badgeState"],
1068
+ "run": run_payload,
1069
+ "jobs": jobs,
1070
+ }
1071
+
1072
+
681
1073
  def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
682
1074
  cdx_which = which or shutil.which
683
1075
  if not cdx_which("cdx"):
@@ -704,6 +1096,123 @@ def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
704
1096
  return {"state": "ok", "message": "", "status": parsed}
705
1097
 
706
1098
 
1099
+ def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1100
+ cdx_which = which or shutil.which
1101
+ if not cdx_which("cdx"):
1102
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "runs": []}
1103
+ try:
1104
+ result = _run_read_only_cdx(repo_root, ["runs", "--json"], runner=runner)
1105
+ except subprocess.TimeoutExpired:
1106
+ return {"state": "timeout", "message": "CDX runs timed out.", "runs": []}
1107
+ except (OSError, subprocess.SubprocessError) as exc:
1108
+ return {"state": "error", "message": f"Unable to run CDX runs: {exc}", "runs": []}
1109
+ if result.returncode != 0:
1110
+ message = (result.stderr or result.stdout or "CDX runs failed.").strip().splitlines()[0]
1111
+ return {"state": "error", "message": message, "runs": []}
1112
+ try:
1113
+ parsed = json.loads(result.stdout or "{}")
1114
+ except json.JSONDecodeError:
1115
+ return {"state": "invalid-json", "message": "CDX runs returned invalid JSON.", "runs": []}
1116
+ runs = parsed.get("runs") if isinstance(parsed, dict) else None
1117
+ if not isinstance(runs, list):
1118
+ return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
1119
+ return {"state": "ok", "message": "", "runs": [run for run in runs if isinstance(run, dict)]}
1120
+
1121
+
1122
+ def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
1123
+ cdx_which = which or shutil.which
1124
+ if not run_id:
1125
+ return {"state": "error", "message": "Missing CDX run id.", "report": None}
1126
+ if not cdx_which("cdx"):
1127
+ return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "report": None}
1128
+ try:
1129
+ result = _run_read_only_cdx(repo_root, ["run-report", run_id, "--json"], runner=runner)
1130
+ except subprocess.TimeoutExpired:
1131
+ return {"state": "timeout", "message": "CDX run report timed out.", "report": None}
1132
+ except (OSError, subprocess.SubprocessError) as exc:
1133
+ return {"state": "error", "message": f"Unable to run CDX run-report: {exc}", "report": None}
1134
+ if result.returncode != 0:
1135
+ message = (result.stderr or result.stdout or "CDX run-report failed.").strip().splitlines()[0]
1136
+ return {"state": "error", "message": message, "report": None}
1137
+ try:
1138
+ parsed = json.loads(result.stdout or "{}")
1139
+ except json.JSONDecodeError:
1140
+ return {"state": "invalid-json", "message": "CDX run-report returned invalid JSON.", "report": None}
1141
+ report = parsed.get("report") if isinstance(parsed, dict) else None
1142
+ if not isinstance(report, dict):
1143
+ return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
1144
+ return {"state": "ok", "message": "", "report": report}
1145
+
1146
+
1147
+ def _slugify_viewer_doc(text: str) -> str:
1148
+ slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
1149
+ return slug[:80] or "cdx_code_review_findings"
1150
+
1151
+
1152
+ def _next_viewer_request_ref(repo_root: Path, title: str) -> str:
1153
+ request_dir = repo_root / "logics" / "request"
1154
+ highest = -1
1155
+ if request_dir.is_dir():
1156
+ for path in request_dir.glob("req_*.md"):
1157
+ match = re.match(r"^req_(\d{3})_", path.stem)
1158
+ if match:
1159
+ highest = max(highest, int(match.group(1)))
1160
+ return f"req_{highest + 1:03d}_{_slugify_viewer_doc(title)}"
1161
+
1162
+
1163
+ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, Any]) -> dict[str, Any]:
1164
+ report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
1165
+ run = report.get("run") if isinstance(report.get("run"), dict) else {}
1166
+ task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
1167
+ run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
1168
+ findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
1169
+ title = f"Address CDX code review findings for {run_id}"
1170
+ ref = _next_viewer_request_ref(repo_root, title)
1171
+ request_dir = repo_root / "logics" / "request"
1172
+ request_dir.mkdir(parents=True, exist_ok=True)
1173
+ rel_path = f"logics/request/{ref}.md"
1174
+ path = repo_root / rel_path
1175
+ finding_lines = []
1176
+ for index, finding in enumerate(findings, start=1):
1177
+ if not isinstance(finding, dict):
1178
+ continue
1179
+ location = finding.get("path") or finding.get("file") or "unknown path"
1180
+ if finding.get("line"):
1181
+ location = f"{location}:{finding['line']}"
1182
+ severity = finding.get("severity") or "unknown"
1183
+ message = finding.get("message") or finding.get("title") or "Review finding"
1184
+ finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
1185
+ if not finding_lines:
1186
+ finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
1187
+ text = "\n".join([
1188
+ f"## {ref} - {title}",
1189
+ "> Status: Draft",
1190
+ "> Understanding: 70%",
1191
+ "> Confidence: 70%",
1192
+ "> Complexity: Medium",
1193
+ "> Theme: Code review follow-up",
1194
+ "",
1195
+ "# Needs",
1196
+ f"- Follow up on CDX code-review run `{run_id}`.",
1197
+ f"- Summary: {task_report.get('summary') or 'No structured summary provided.'}",
1198
+ "",
1199
+ "# Findings",
1200
+ *finding_lines,
1201
+ "",
1202
+ "# Traceability",
1203
+ f"- CDX run id: `{run_id}`",
1204
+ f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
1205
+ f"- Stdout: `{(report.get('artifacts') or {}).get('stdout_path') or ''}`",
1206
+ "",
1207
+ "# Acceptance Criteria",
1208
+ "- AC1: Each actionable finding is reviewed and either fixed, documented as not applicable, or split into follow-up work.",
1209
+ "- AC2: Validation evidence is added before closing this request.",
1210
+ "",
1211
+ ])
1212
+ path.write_text(text, encoding="utf-8")
1213
+ return {"id": ref, "path": rel_path, "title": title}
1214
+
1215
+
707
1216
  def _json_bytes(payload: Any) -> bytes:
708
1217
  return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
709
1218
 
@@ -716,10 +1225,35 @@ class LogicsViewerServer(ThreadingHTTPServer):
716
1225
  *,
717
1226
  auto_refresh_interval_seconds: int = 15,
718
1227
  ):
719
- self.repo_root = repo_root.resolve()
1228
+ self.launch_repo_root = repo_root.resolve()
1229
+ self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
1230
+ self.project_root_by_id = {_viewer_project_id(root): root.resolve() for root in self.project_roots}
1231
+ self.active_project_id = _viewer_project_id(self.launch_repo_root)
1232
+ self.repo_root = self.launch_repo_root
720
1233
  self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
721
1234
  super().__init__(server_address, LogicsViewerRequestHandler)
722
1235
 
1236
+ def project_registry_payload(self) -> list[dict[str, Any]]:
1237
+ return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
1238
+
1239
+ def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
1240
+ return viewer_data_payload(
1241
+ self.repo_root,
1242
+ selected_id=selected_id,
1243
+ auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
1244
+ projects=self.project_registry_payload(),
1245
+ )
1246
+
1247
+ def switch_project(self, project_id: str) -> dict[str, Any]:
1248
+ target = self.project_root_by_id.get(project_id)
1249
+ if target is None:
1250
+ raise ValueError("Unknown project id.")
1251
+ if not target.is_dir():
1252
+ raise FileNotFoundError(str(target))
1253
+ self.active_project_id = project_id
1254
+ self.repo_root = target
1255
+ return self.viewer_payload()
1256
+
723
1257
 
724
1258
  class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
725
1259
  server: LogicsViewerServer
@@ -783,13 +1317,13 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
783
1317
  self._send_json(
784
1318
  {
785
1319
  "ok": True,
786
- "payload": viewer_data_payload(
787
- self.server.repo_root,
788
- auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
789
- ),
1320
+ "payload": self.server.viewer_payload(),
790
1321
  }
791
1322
  )
792
1323
  return
1324
+ if route == "/api/projects":
1325
+ self._send_json({"ok": True, "payload": {"projects": self.server.project_registry_payload()}})
1326
+ return
793
1327
  if route == "/api/doc":
794
1328
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
795
1329
  try:
@@ -803,12 +1337,25 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
803
1337
  if route == "/api/audit":
804
1338
  self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
805
1339
  return
1340
+ if route == "/api/capabilities":
1341
+ self._send_json({"ok": True, "payload": viewer_project_capabilities(self.server.repo_root)})
1342
+ return
806
1343
  if route == "/api/git-status":
807
1344
  self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
808
1345
  return
1346
+ if route == "/api/ci-status":
1347
+ self._send_json({"ok": True, "payload": ci_status_payload(self.server.repo_root)})
1348
+ return
809
1349
  if route == "/api/cdx-status":
810
1350
  self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
811
1351
  return
1352
+ if route == "/api/cdx-runs":
1353
+ self._send_json({"ok": True, "payload": cdx_runs_payload(self.server.repo_root)})
1354
+ return
1355
+ if route == "/api/cdx-run-report":
1356
+ run_id = parse_qs(parsed.query).get("runId", [""])[0]
1357
+ self._send_json({"ok": True, "payload": cdx_run_report_payload(self.server.repo_root, run_id)})
1358
+ return
812
1359
  if route == "/api/git-diff":
813
1360
  params = parse_qs(parsed.query)
814
1361
  rel_path = params.get("path", [""])[0]
@@ -823,13 +1370,49 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
823
1370
  self._send_json(
824
1371
  {
825
1372
  "ok": True,
826
- "payload": viewer_data_payload(
827
- self.server.repo_root,
828
- auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
829
- ),
1373
+ "payload": self.server.viewer_payload(),
830
1374
  }
831
1375
  )
832
1376
  return
1377
+ if parsed.path == "/api/switch-project":
1378
+ try:
1379
+ length = int(self.headers.get("Content-Length", "0") or "0")
1380
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
1381
+ body = json.loads(raw_body or "{}")
1382
+ project_id = str(body.get("projectId") or "")
1383
+ self._send_json({"ok": True, "payload": self.server.switch_project(project_id)})
1384
+ except json.JSONDecodeError:
1385
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
1386
+ except ValueError as exc:
1387
+ self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
1388
+ except FileNotFoundError as exc:
1389
+ self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
1390
+ return
1391
+ if parsed.path == "/api/bootstrap-logics":
1392
+ try:
1393
+ bootstrap = bootstrap_payload(self.server.repo_root, check=False)
1394
+ self._send_json({"ok": True, "payload": self.server.viewer_payload(), "bootstrap": bootstrap})
1395
+ except SystemExit as exc:
1396
+ self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
1397
+ except OSError as exc:
1398
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1399
+ return
1400
+ if parsed.path == "/api/cdx-report-request":
1401
+ try:
1402
+ length = int(self.headers.get("Content-Length", "0") or "0")
1403
+ raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
1404
+ body = json.loads(raw_body or "{}")
1405
+ report_payload = cdx_run_report_payload(self.server.repo_root, str(body.get("runId") or ""))
1406
+ if report_payload.get("state") != "ok":
1407
+ self._send_error_json(HTTPStatus.BAD_GATEWAY, str(report_payload.get("message") or "Unable to load CDX report."))
1408
+ return
1409
+ created = create_request_from_cdx_report(self.server.repo_root, report_payload)
1410
+ self._send_json({"ok": True, "created": created, "payload": self.server.viewer_payload(selected_id=created["id"])})
1411
+ except json.JSONDecodeError:
1412
+ self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
1413
+ except OSError as exc:
1414
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1415
+ return
833
1416
  if parsed.path == "/api/edit":
834
1417
  rel_path = parse_qs(parsed.query).get("path", [""])[0]
835
1418
  try:
@@ -839,6 +1422,12 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
839
1422
  except OSError as exc:
840
1423
  self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
841
1424
  return
1425
+ if parsed.path == "/api/open-repo-folder":
1426
+ try:
1427
+ self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
1428
+ except OSError as exc:
1429
+ self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
1430
+ return
842
1431
  self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
843
1432
 
844
1433