@grifhinz/logics-manager 2.7.0 → 2.8.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.
- package/README.md +37 -3
- package/VERSION +1 -1
- package/clients/viewer/browser-host.js +1181 -83
- package/clients/viewer/index.html +1 -0
- package/clients/viewer/viewer.css +291 -1
- package/logics_manager/cli.py +5 -3
- package/logics_manager/flow.py +61 -1
- package/logics_manager/sync.py +47 -19
- package/logics_manager/viewer.py +973 -16
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -44,6 +44,104 @@ DOC_FAMILIES = (
|
|
|
44
44
|
)
|
|
45
45
|
|
|
46
46
|
STAGE_ORDER = {family.stage: index for index, family in enumerate(DOC_FAMILIES)}
|
|
47
|
+
CDX_MISSION_STRENGTHS = {
|
|
48
|
+
"standard": {"id": "standard", "label": "Standard", "timeout": 180, "reasoningEffort": "medium", "power": "medium"},
|
|
49
|
+
"deep": {"id": "deep", "label": "Deep", "timeout": 300, "reasoningEffort": "high", "power": "high"},
|
|
50
|
+
"max": {"id": "max", "label": "Max", "timeout": 600, "reasoningEffort": "high", "power": "high"},
|
|
51
|
+
}
|
|
52
|
+
CDX_MISSION_CATALOG = {
|
|
53
|
+
"full-audit": {
|
|
54
|
+
"id": "full-audit",
|
|
55
|
+
"title": "Full audit",
|
|
56
|
+
"description": "Audit the repository and optionally apply safe, validated fixes.",
|
|
57
|
+
"scope": "repository",
|
|
58
|
+
"requiresReleaseTag": False,
|
|
59
|
+
"requiresPlanConfirmation": False,
|
|
60
|
+
"supportsFileWrites": True,
|
|
61
|
+
"inputFields": [
|
|
62
|
+
{
|
|
63
|
+
"id": "directFixes",
|
|
64
|
+
"label": "Fix directly",
|
|
65
|
+
"type": "checkbox",
|
|
66
|
+
"required": False,
|
|
67
|
+
}
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
"release-review": {
|
|
71
|
+
"id": "release-review",
|
|
72
|
+
"title": "Review since latest release",
|
|
73
|
+
"description": "Review changes since the latest release and optionally apply safe fixes.",
|
|
74
|
+
"scope": "latest-release",
|
|
75
|
+
"requiresReleaseTag": True,
|
|
76
|
+
"requiresPlanConfirmation": False,
|
|
77
|
+
"supportsFileWrites": True,
|
|
78
|
+
"inputFields": [
|
|
79
|
+
{
|
|
80
|
+
"id": "directFixes",
|
|
81
|
+
"label": "Fix directly",
|
|
82
|
+
"type": "checkbox",
|
|
83
|
+
"required": False,
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
},
|
|
87
|
+
"corpus-ready": {
|
|
88
|
+
"id": "corpus-ready",
|
|
89
|
+
"title": "Prepare dev-ready corpus",
|
|
90
|
+
"description": "Produce a corpus plan for explicit deterministic application.",
|
|
91
|
+
"scope": "open-logics-workflow",
|
|
92
|
+
"requiresReleaseTag": False,
|
|
93
|
+
"requiresPlanConfirmation": True,
|
|
94
|
+
"supportsFileWrites": False,
|
|
95
|
+
},
|
|
96
|
+
"wish-to-request": {
|
|
97
|
+
"id": "wish-to-request",
|
|
98
|
+
"title": "Wish to request",
|
|
99
|
+
"description": "Create or draft a structured Logics request from a free-form wish.",
|
|
100
|
+
"scope": "request-draft",
|
|
101
|
+
"requiresReleaseTag": False,
|
|
102
|
+
"requiresPlanConfirmation": False,
|
|
103
|
+
"supportsFileWrites": True,
|
|
104
|
+
"inputFields": [
|
|
105
|
+
{
|
|
106
|
+
"id": "wishText",
|
|
107
|
+
"label": "Wish or intent",
|
|
108
|
+
"type": "textarea",
|
|
109
|
+
"placeholder": "Describe the workflow, feature, bug, or product intent to capture.",
|
|
110
|
+
"required": True,
|
|
111
|
+
}
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
"pre-release": {
|
|
115
|
+
"id": "pre-release",
|
|
116
|
+
"title": "Guarded pre-release",
|
|
117
|
+
"description": "Prepare release metadata, changelog, validation, and fixes without tagging or publishing.",
|
|
118
|
+
"scope": "pre-release-report",
|
|
119
|
+
"requiresReleaseTag": False,
|
|
120
|
+
"requiresPlanConfirmation": False,
|
|
121
|
+
"supportsFileWrites": True,
|
|
122
|
+
"inputFields": [
|
|
123
|
+
{
|
|
124
|
+
"id": "releaseVersion",
|
|
125
|
+
"label": "Version",
|
|
126
|
+
"type": "text",
|
|
127
|
+
"placeholder": "vX.X.X",
|
|
128
|
+
"required": True,
|
|
129
|
+
"pattern": "^v\\d+\\.\\d+\\.\\d+$",
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
"id": "runFullValidation",
|
|
133
|
+
"label": "Run full validation and report fixes before pre-release",
|
|
134
|
+
"type": "checkbox",
|
|
135
|
+
"required": False,
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
},
|
|
139
|
+
}
|
|
140
|
+
CDX_DEFAULT_MISSION_ID = "full-audit"
|
|
141
|
+
GIT_FILE_PREVIEW_MAX_BYTES = 30000
|
|
142
|
+
GIT_FILE_PREVIEW_MAX_CHARS = 20000
|
|
143
|
+
FILE_PREVIEW_MAX_BYTES = 300000
|
|
144
|
+
FILE_PREVIEW_MAX_CHARS = 200000
|
|
47
145
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
48
146
|
PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
|
|
49
147
|
VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
|
|
@@ -553,6 +651,54 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
|
|
|
553
651
|
}
|
|
554
652
|
|
|
555
653
|
|
|
654
|
+
def _resolve_openable_file_path(repo_root: Path, file_path: str) -> Path:
|
|
655
|
+
raw_path = unquote(file_path).strip()
|
|
656
|
+
if not raw_path:
|
|
657
|
+
raise ValueError("Missing file path.")
|
|
658
|
+
candidate = Path(raw_path).expanduser()
|
|
659
|
+
if not candidate.is_absolute():
|
|
660
|
+
candidate = repo_root / raw_path.lstrip("/\\")
|
|
661
|
+
absolute = candidate.resolve()
|
|
662
|
+
if not absolute.is_file():
|
|
663
|
+
raise FileNotFoundError(str(candidate))
|
|
664
|
+
return absolute
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def open_file_payload(repo_root: Path, file_path: str, *, launcher: Any | None = None) -> dict[str, str]:
|
|
668
|
+
absolute = _resolve_openable_file_path(repo_root, file_path)
|
|
669
|
+
command = _system_editor_command(absolute)
|
|
670
|
+
runner = launcher or subprocess.Popen
|
|
671
|
+
runner(command)
|
|
672
|
+
return {
|
|
673
|
+
"path": str(absolute),
|
|
674
|
+
"command": command[0],
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
|
|
678
|
+
def file_preview_payload(
|
|
679
|
+
repo_root: Path,
|
|
680
|
+
file_path: str,
|
|
681
|
+
*,
|
|
682
|
+
max_bytes: int = FILE_PREVIEW_MAX_BYTES,
|
|
683
|
+
max_chars: int = FILE_PREVIEW_MAX_CHARS,
|
|
684
|
+
) -> dict[str, Any]:
|
|
685
|
+
absolute = _resolve_openable_file_path(repo_root, file_path)
|
|
686
|
+
raw = absolute.read_bytes()
|
|
687
|
+
truncated = len(raw) > max_bytes
|
|
688
|
+
if truncated:
|
|
689
|
+
raw = raw[-max_bytes:]
|
|
690
|
+
content = raw.decode("utf-8", errors="replace")
|
|
691
|
+
if len(content) > max_chars:
|
|
692
|
+
content = content[-max_chars:]
|
|
693
|
+
truncated = True
|
|
694
|
+
return {
|
|
695
|
+
"path": str(absolute),
|
|
696
|
+
"name": absolute.name,
|
|
697
|
+
"content": content,
|
|
698
|
+
"truncated": truncated,
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
|
|
556
702
|
def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
|
|
557
703
|
root = repo_root.resolve()
|
|
558
704
|
command = _system_editor_command(root)
|
|
@@ -584,6 +730,24 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
|
|
|
584
730
|
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
585
731
|
|
|
586
732
|
|
|
733
|
+
def _run_cdx_mission(repo_root: Path, args: list[str], *, timeout: int, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
734
|
+
command = ["cdx", *args]
|
|
735
|
+
cdx_runner = runner or subprocess.run
|
|
736
|
+
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=timeout)
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _run_logics_flow(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
740
|
+
command = ["logics-manager", "flow", *args]
|
|
741
|
+
flow_runner = runner or subprocess.run
|
|
742
|
+
return flow_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _run_logics_command(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
746
|
+
command = ["logics-manager", *args]
|
|
747
|
+
logics_runner = runner or subprocess.run
|
|
748
|
+
return logics_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
|
|
749
|
+
|
|
750
|
+
|
|
587
751
|
def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
588
752
|
command = ["gh", *args]
|
|
589
753
|
gh_runner = runner or subprocess.run
|
|
@@ -711,7 +875,11 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
|
|
|
711
875
|
}
|
|
712
876
|
|
|
713
877
|
|
|
714
|
-
|
|
878
|
+
GIT_HISTORY_DISPLAY_LIMIT = 50
|
|
879
|
+
GIT_HISTORY_FETCH_LIMIT = GIT_HISTORY_DISPLAY_LIMIT + 1
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _parse_recent_git_commits(output: str, *, limit: int | None = None) -> list[dict[str, str]]:
|
|
715
883
|
commits: list[dict[str, str]] = []
|
|
716
884
|
for line in output.splitlines():
|
|
717
885
|
parts = line.split("\x1f")
|
|
@@ -727,6 +895,8 @@ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
|
|
|
727
895
|
"refs": _sanitize_git_ref(refs),
|
|
728
896
|
}
|
|
729
897
|
)
|
|
898
|
+
if limit is not None and len(commits) >= limit:
|
|
899
|
+
break
|
|
730
900
|
return commits
|
|
731
901
|
|
|
732
902
|
|
|
@@ -813,7 +983,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
813
983
|
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
814
984
|
recent_commits = _run_read_only_git(
|
|
815
985
|
repo_root,
|
|
816
|
-
["log", "-
|
|
986
|
+
["log", f"-{GIT_HISTORY_FETCH_LIMIT}", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
817
987
|
runner=runner,
|
|
818
988
|
)
|
|
819
989
|
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
@@ -840,6 +1010,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
840
1010
|
counts = {key: len(value) for key, value in groups.items()}
|
|
841
1011
|
uncommitted_files = _count_unique_git_status_paths(groups)
|
|
842
1012
|
dirty = any(counts.values())
|
|
1013
|
+
parsed_recent_commits = _parse_recent_git_commits(recent_commits.stdout, limit=GIT_HISTORY_FETCH_LIMIT) if recent_commits.returncode == 0 else []
|
|
843
1014
|
return {
|
|
844
1015
|
"state": "ok",
|
|
845
1016
|
**branch_info,
|
|
@@ -860,10 +1031,18 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
860
1031
|
},
|
|
861
1032
|
"groups": groups,
|
|
862
1033
|
"latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
|
|
863
|
-
"recentCommits":
|
|
1034
|
+
"recentCommits": parsed_recent_commits[:GIT_HISTORY_DISPLAY_LIMIT],
|
|
1035
|
+
"recentCommitsHasMore": len(parsed_recent_commits) > GIT_HISTORY_DISPLAY_LIMIT,
|
|
864
1036
|
}
|
|
865
1037
|
|
|
866
1038
|
|
|
1039
|
+
def _normalize_git_file_path(rel_path: str) -> str | None:
|
|
1040
|
+
normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
|
|
1041
|
+
if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
|
|
1042
|
+
return None
|
|
1043
|
+
return normalized
|
|
1044
|
+
|
|
1045
|
+
|
|
867
1046
|
def git_diff_payload(
|
|
868
1047
|
repo_root: Path,
|
|
869
1048
|
rel_path: str,
|
|
@@ -876,8 +1055,8 @@ def git_diff_payload(
|
|
|
876
1055
|
git_which = which or shutil.which
|
|
877
1056
|
if not git_which("git"):
|
|
878
1057
|
return {"state": "unavailable", "message": "Git is not available on PATH."}
|
|
879
|
-
normalized =
|
|
880
|
-
if not normalized
|
|
1058
|
+
normalized = _normalize_git_file_path(rel_path)
|
|
1059
|
+
if not normalized:
|
|
881
1060
|
return {"state": "error", "message": "Unsafe Git path."}
|
|
882
1061
|
try:
|
|
883
1062
|
inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
@@ -912,6 +1091,71 @@ def git_diff_payload(
|
|
|
912
1091
|
}
|
|
913
1092
|
|
|
914
1093
|
|
|
1094
|
+
def git_file_preview_payload(
|
|
1095
|
+
repo_root: Path,
|
|
1096
|
+
rel_path: str,
|
|
1097
|
+
*,
|
|
1098
|
+
max_bytes: int = GIT_FILE_PREVIEW_MAX_BYTES,
|
|
1099
|
+
max_chars: int = GIT_FILE_PREVIEW_MAX_CHARS,
|
|
1100
|
+
) -> dict[str, Any]:
|
|
1101
|
+
normalized = _normalize_git_file_path(rel_path)
|
|
1102
|
+
if not normalized:
|
|
1103
|
+
return {"state": "error", "message": "Unsafe Git path."}
|
|
1104
|
+
target = (repo_root / normalized).resolve()
|
|
1105
|
+
try:
|
|
1106
|
+
target.relative_to(repo_root.resolve())
|
|
1107
|
+
except ValueError:
|
|
1108
|
+
return {"state": "error", "message": "Unsafe Git path."}
|
|
1109
|
+
if not target.exists() or not target.is_file():
|
|
1110
|
+
return {
|
|
1111
|
+
"state": "missing",
|
|
1112
|
+
"path": normalized,
|
|
1113
|
+
"message": "The current file is missing or deleted, so no file preview is available.",
|
|
1114
|
+
}
|
|
1115
|
+
try:
|
|
1116
|
+
size = target.stat().st_size
|
|
1117
|
+
except OSError as exc:
|
|
1118
|
+
return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
|
|
1119
|
+
if size > max_bytes:
|
|
1120
|
+
return {
|
|
1121
|
+
"state": "oversized",
|
|
1122
|
+
"path": normalized,
|
|
1123
|
+
"size": size,
|
|
1124
|
+
"message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
|
|
1125
|
+
}
|
|
1126
|
+
try:
|
|
1127
|
+
data = target.read_bytes()
|
|
1128
|
+
except OSError as exc:
|
|
1129
|
+
return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
|
|
1130
|
+
if b"\x00" in data:
|
|
1131
|
+
return {
|
|
1132
|
+
"state": "unsupported",
|
|
1133
|
+
"path": normalized,
|
|
1134
|
+
"message": "Binary or unsupported file content cannot be previewed.",
|
|
1135
|
+
}
|
|
1136
|
+
try:
|
|
1137
|
+
content = data.decode("utf-8")
|
|
1138
|
+
except UnicodeDecodeError:
|
|
1139
|
+
return {
|
|
1140
|
+
"state": "unsupported",
|
|
1141
|
+
"path": normalized,
|
|
1142
|
+
"message": "Binary or unsupported file encoding cannot be previewed.",
|
|
1143
|
+
}
|
|
1144
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
1145
|
+
truncated = len(content) > max_chars
|
|
1146
|
+
if truncated:
|
|
1147
|
+
content = content[:max_chars]
|
|
1148
|
+
return {
|
|
1149
|
+
"state": "ok",
|
|
1150
|
+
"path": normalized,
|
|
1151
|
+
"mode": "file-preview",
|
|
1152
|
+
"content": content,
|
|
1153
|
+
"truncated": truncated,
|
|
1154
|
+
"logicsType": _logics_doc_type(normalized),
|
|
1155
|
+
"message": "",
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
|
|
915
1159
|
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
916
1160
|
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
917
1161
|
commands = {
|
|
@@ -946,11 +1190,41 @@ def _ci_badge_state(status: str, conclusion: str) -> str:
|
|
|
946
1190
|
return "unknown"
|
|
947
1191
|
|
|
948
1192
|
|
|
1193
|
+
def _is_active_ci_status(run: dict[str, Any]) -> bool:
|
|
1194
|
+
return str(run.get("status") or "").strip().lower() in {"queued", "in_progress", "waiting", "requested", "pending"}
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
|
|
1198
|
+
head_runs = [run for run in runs if head_sha and str(run.get("head_sha") or "") == head_sha]
|
|
1199
|
+
active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
|
|
1200
|
+
if active_head_run is not None:
|
|
1201
|
+
return active_head_run, "head-active"
|
|
1202
|
+
failing_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
|
|
1203
|
+
if failing_head_run is not None:
|
|
1204
|
+
return failing_head_run, "head-failing"
|
|
1205
|
+
cancelled_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "cancelled"), None)
|
|
1206
|
+
if cancelled_head_run is not None:
|
|
1207
|
+
return cancelled_head_run, "head-cancelled"
|
|
1208
|
+
unknown_head_run = next((run for run in head_runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "unknown"), None)
|
|
1209
|
+
if unknown_head_run is not None:
|
|
1210
|
+
return unknown_head_run, "head-unknown"
|
|
1211
|
+
if head_runs:
|
|
1212
|
+
return head_runs[0], "head"
|
|
1213
|
+
active_branch_run = next((run for run in runs if _is_active_ci_status(run)), None)
|
|
1214
|
+
if active_branch_run is not None:
|
|
1215
|
+
return active_branch_run, "branch-active"
|
|
1216
|
+
failing_branch_run = next((run for run in runs if _ci_badge_state(str(run.get("status") or ""), str(run.get("conclusion") or "")) == "failing"), None)
|
|
1217
|
+
if failing_branch_run is not None:
|
|
1218
|
+
return failing_branch_run, "branch-failing"
|
|
1219
|
+
return runs[0], "branch-latest"
|
|
1220
|
+
|
|
1221
|
+
|
|
949
1222
|
def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
|
|
950
1223
|
status = str(run.get("status") or "")
|
|
951
1224
|
conclusion = str(run.get("conclusion") or "")
|
|
952
1225
|
commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
|
|
953
1226
|
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
|
1227
|
+
commit_lines = str(commit.get("message") or run.get("display_title") or "").splitlines()
|
|
954
1228
|
return {
|
|
955
1229
|
"id": run.get("id"),
|
|
956
1230
|
"name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
|
|
@@ -965,7 +1239,7 @@ def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict
|
|
|
965
1239
|
"createdAt": str(run.get("created_at") or ""),
|
|
966
1240
|
"updatedAt": str(run.get("updated_at") or ""),
|
|
967
1241
|
"runStartedAt": str(run.get("run_started_at") or ""),
|
|
968
|
-
"commitMessage":
|
|
1242
|
+
"commitMessage": commit_lines[0][:240] if commit_lines else "",
|
|
969
1243
|
"author": str(author.get("name") or ""),
|
|
970
1244
|
"matchSource": match_source,
|
|
971
1245
|
}
|
|
@@ -1021,7 +1295,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
|
|
|
1021
1295
|
context = _current_git_ci_context(repo_root, runner=git_runner)
|
|
1022
1296
|
branch = context.get("branch", "")
|
|
1023
1297
|
head_sha = context.get("headSha", "")
|
|
1024
|
-
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=
|
|
1298
|
+
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=30"
|
|
1025
1299
|
if branch:
|
|
1026
1300
|
endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
|
|
1027
1301
|
try:
|
|
@@ -1043,10 +1317,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
|
|
|
1043
1317
|
if not runs:
|
|
1044
1318
|
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
1319
|
|
|
1046
|
-
selected =
|
|
1047
|
-
match_source = "head" if selected else "branch-latest"
|
|
1048
|
-
if selected is None:
|
|
1049
|
-
selected = runs[0]
|
|
1320
|
+
selected, match_source = _select_github_actions_run(runs, head_sha)
|
|
1050
1321
|
run_payload = _parse_github_actions_run(selected, match_source=match_source)
|
|
1051
1322
|
jobs: list[dict[str, str]] = []
|
|
1052
1323
|
run_id = run_payload.get("id")
|
|
@@ -1116,7 +1387,18 @@ def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any |
|
|
|
1116
1387
|
runs = parsed.get("runs") if isinstance(parsed, dict) else None
|
|
1117
1388
|
if not isinstance(runs, list):
|
|
1118
1389
|
return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
|
|
1119
|
-
|
|
1390
|
+
normalized_runs: list[dict[str, Any]] = []
|
|
1391
|
+
for run in runs:
|
|
1392
|
+
if not isinstance(run, dict):
|
|
1393
|
+
continue
|
|
1394
|
+
item = dict(run)
|
|
1395
|
+
status = str(item.get("status") or item.get("state") or "").strip().lower()
|
|
1396
|
+
if status == "stale" and not item.get("ended_at") and not item.get("endedAt"):
|
|
1397
|
+
item["status"] = "running"
|
|
1398
|
+
item["status_detail"] = "CDX still marks this run active; no end timestamp has been reported yet."
|
|
1399
|
+
item["raw_status"] = "stale"
|
|
1400
|
+
normalized_runs.append(item)
|
|
1401
|
+
return {"state": "ok", "message": "", "runs": normalized_runs}
|
|
1120
1402
|
|
|
1121
1403
|
|
|
1122
1404
|
def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
@@ -1141,9 +1423,560 @@ def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None =
|
|
|
1141
1423
|
report = parsed.get("report") if isinstance(parsed, dict) else None
|
|
1142
1424
|
if not isinstance(report, dict):
|
|
1143
1425
|
return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
|
|
1426
|
+
merged_report = _merge_cdx_mission_output(report)
|
|
1427
|
+
if merged_report:
|
|
1428
|
+
report = merged_report
|
|
1144
1429
|
return {"state": "ok", "message": "", "report": report}
|
|
1145
1430
|
|
|
1146
1431
|
|
|
1432
|
+
def cdx_mission_catalog_payload() -> dict[str, Any]:
|
|
1433
|
+
return {
|
|
1434
|
+
"missions": list(CDX_MISSION_CATALOG.values()),
|
|
1435
|
+
"strengths": list(CDX_MISSION_STRENGTHS.values()),
|
|
1436
|
+
"defaultMissionId": CDX_DEFAULT_MISSION_ID,
|
|
1437
|
+
"defaultStrengthId": "standard",
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def _cdx_status_sessions(status_payload: dict[str, Any]) -> list[str]:
|
|
1442
|
+
status = status_payload.get("status") if isinstance(status_payload.get("status"), dict) else {}
|
|
1443
|
+
sessions = status.get("sessions") if isinstance(status.get("sessions"), list) else []
|
|
1444
|
+
ids: list[str] = []
|
|
1445
|
+
for session in sessions:
|
|
1446
|
+
if not isinstance(session, dict):
|
|
1447
|
+
continue
|
|
1448
|
+
session_id = str(session.get("id") or session.get("name") or "").strip()
|
|
1449
|
+
if session_id:
|
|
1450
|
+
ids.append(session_id)
|
|
1451
|
+
return ids
|
|
1452
|
+
|
|
1453
|
+
|
|
1454
|
+
def _normalize_cdx_session(value: Any, status_payload: dict[str, Any] | None = None) -> str:
|
|
1455
|
+
session = str(value or "").strip()
|
|
1456
|
+
if not re.match(r"^[A-Za-z0-9_.:@/-]{1,120}$", session):
|
|
1457
|
+
return ""
|
|
1458
|
+
if status_payload is None:
|
|
1459
|
+
return session
|
|
1460
|
+
known_sessions = _cdx_status_sessions(status_payload)
|
|
1461
|
+
if known_sessions and session not in known_sessions:
|
|
1462
|
+
return ""
|
|
1463
|
+
return session
|
|
1464
|
+
|
|
1465
|
+
|
|
1466
|
+
def _latest_release_tag(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
|
|
1467
|
+
git_which = which or shutil.which
|
|
1468
|
+
if not git_which("git"):
|
|
1469
|
+
return ""
|
|
1470
|
+
commands = [
|
|
1471
|
+
["tag", "--sort=-version:refname", "--list", "v[0-9]*"],
|
|
1472
|
+
["tag", "--sort=-version:refname", "--list", "[0-9]*"],
|
|
1473
|
+
["describe", "--tags", "--abbrev=0"],
|
|
1474
|
+
]
|
|
1475
|
+
for args in commands:
|
|
1476
|
+
try:
|
|
1477
|
+
result = _run_read_only_git(repo_root, args, runner=runner)
|
|
1478
|
+
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
1479
|
+
continue
|
|
1480
|
+
if result.returncode != 0:
|
|
1481
|
+
continue
|
|
1482
|
+
tag = (result.stdout or "").strip().splitlines()[0] if (result.stdout or "").strip() else ""
|
|
1483
|
+
if tag:
|
|
1484
|
+
return tag[:200]
|
|
1485
|
+
return ""
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _mission_text_input(body: dict[str, Any], key: str, *, max_chars: int = 4000) -> str:
|
|
1489
|
+
raw = str(body.get(key) or "").strip()
|
|
1490
|
+
normalized = re.sub(r"\s+", " ", raw)
|
|
1491
|
+
return normalized[:max_chars]
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
def _mission_bool_input(body: dict[str, Any], key: str) -> bool:
|
|
1495
|
+
value = body.get(key)
|
|
1496
|
+
if isinstance(value, bool):
|
|
1497
|
+
return value
|
|
1498
|
+
if isinstance(value, str):
|
|
1499
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
1500
|
+
return False
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _cdx_mission_prompt(
|
|
1504
|
+
mission_id: str,
|
|
1505
|
+
*,
|
|
1506
|
+
release_tag: str = "",
|
|
1507
|
+
wish_text: str = "",
|
|
1508
|
+
release_version: str = "",
|
|
1509
|
+
run_full_validation: bool = False,
|
|
1510
|
+
allow_file_writes: bool = False,
|
|
1511
|
+
direct_fixes: bool = False,
|
|
1512
|
+
commit_at_end: bool = False,
|
|
1513
|
+
) -> str:
|
|
1514
|
+
write_guidance = (
|
|
1515
|
+
"File edits are allowed when they directly complete the selected mission mode. Keep changes scoped, run relevant validation, and report changed files."
|
|
1516
|
+
if allow_file_writes
|
|
1517
|
+
else "Do not modify files."
|
|
1518
|
+
)
|
|
1519
|
+
commit_guidance = (
|
|
1520
|
+
"At the end, if and only if files were added, deleted, or modified, create one scoped git commit that includes all mission changes. Do not push, tag, publish, upload assets, or create a GitHub release. Include the commit hash and message in the returned JSON when a commit is created."
|
|
1521
|
+
if commit_at_end
|
|
1522
|
+
else "Do not create git commits."
|
|
1523
|
+
)
|
|
1524
|
+
if mission_id == "full-audit":
|
|
1525
|
+
if direct_fixes:
|
|
1526
|
+
action_guidance = "Fix safe, scoped issues directly in repository files when you can validate them. Do not write a separate audit corpus/report artifact. Do not make broad refactors, release, tag, push, or publish."
|
|
1527
|
+
schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
|
|
1528
|
+
elif allow_file_writes:
|
|
1529
|
+
action_guidance = "Create or update a bounded Logics request under logics/request/ for actionable full-audit follow-up. Do not write a separate audit corpus/report artifact. Do not directly modify product/source files to fix issues."
|
|
1530
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
|
|
1531
|
+
else:
|
|
1532
|
+
action_guidance = "Report only; do not write corpus files, fix issues, or modify files."
|
|
1533
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations."
|
|
1534
|
+
return "\n".join([
|
|
1535
|
+
"Run a full repository audit for this Logics Manager checkout.",
|
|
1536
|
+
"Focus on correctness bugs, workflow risks, missing validation, stale documentation, and test gaps.",
|
|
1537
|
+
write_guidance,
|
|
1538
|
+
action_guidance,
|
|
1539
|
+
commit_guidance,
|
|
1540
|
+
schema,
|
|
1541
|
+
])
|
|
1542
|
+
if mission_id == "release-review":
|
|
1543
|
+
if direct_fixes:
|
|
1544
|
+
action_guidance = "Fix safe, scoped release-readiness issues directly in repository files when you can validate them, such as stale documentation, missing release notes, or narrow test failures. Do not write a separate release-review corpus/report artifact. Do not bump versions unless explicitly requested, and do not tag, push, publish, upload assets, or create GitHub releases."
|
|
1545
|
+
schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
|
|
1546
|
+
elif allow_file_writes:
|
|
1547
|
+
action_guidance = "Create or update a bounded Logics request under logics/request/ for actionable release-review follow-up. Do not write a separate release-review corpus/report artifact under logics/external. Do not directly modify product/source files to fix issues. Do not bump versions, tag, push, publish, upload assets, or create GitHub releases."
|
|
1548
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
|
|
1549
|
+
else:
|
|
1550
|
+
action_guidance = "Report only; do not update release files, write corpus files, fix issues, tag, push, publish, upload assets, or create GitHub releases."
|
|
1551
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations."
|
|
1552
|
+
return "\n".join([
|
|
1553
|
+
f"Review repository changes since the latest release tag {release_tag}.",
|
|
1554
|
+
"Focus on regressions, incomplete release notes, migration risks, and missing tests.",
|
|
1555
|
+
write_guidance,
|
|
1556
|
+
action_guidance,
|
|
1557
|
+
commit_guidance,
|
|
1558
|
+
schema,
|
|
1559
|
+
])
|
|
1560
|
+
if mission_id == "corpus-ready":
|
|
1561
|
+
return "\n".join([
|
|
1562
|
+
"Prepare the open Logics workflow corpus for development.",
|
|
1563
|
+
"Analyze requests, backlog items, tasks, docs, lint/audit state, and workflow consistency.",
|
|
1564
|
+
"Do not modify files directly. This mission is plan-first: return allowed actions for the viewer to apply explicitly.",
|
|
1565
|
+
"Do not run destructive commands.",
|
|
1566
|
+
"Return JSON only with this schema:",
|
|
1567
|
+
'{"summary":"...","actions":[{"type":"promote-request-to-backlog","target":"req_..."},{"type":"promote-backlog-to-task","target":"item_..."},{"type":"refresh-corpus-context","target":""}],"notes":["..."]}',
|
|
1568
|
+
"Allowed action types are exactly: promote-request-to-backlog, promote-backlog-to-task, refresh-corpus-context.",
|
|
1569
|
+
"Use only targets that exist in the repository. Omit actions that are not clearly justified.",
|
|
1570
|
+
])
|
|
1571
|
+
if mission_id == "wish-to-request":
|
|
1572
|
+
request_guidance = (
|
|
1573
|
+
"Create the request draft file under logics/request/ using the next available req_ slug. Keep the file as a request draft only; do not promote backlog items and do not create tasks. Include the created path in generatedFiles."
|
|
1574
|
+
if allow_file_writes
|
|
1575
|
+
else "Do not create the request file; return the request draft and generatedFiles preview only."
|
|
1576
|
+
)
|
|
1577
|
+
return "\n".join([
|
|
1578
|
+
"Turn the following user wish into a structured Logics request draft.",
|
|
1579
|
+
write_guidance,
|
|
1580
|
+
request_guidance,
|
|
1581
|
+
commit_guidance,
|
|
1582
|
+
"Do not promote backlog items and do not create tasks.",
|
|
1583
|
+
"Return JSON only with this schema:",
|
|
1584
|
+
'{"summary":"...","requestDraft":{"title":"...","needs":["..."],"context":["..."],"acceptanceCriteria":["AC1: ..."],"definitionOfReady":{"problemExplicit":true,"scopeBounded":true,"criteriaTestable":true,"risksListed":true},"references":["..."],"questions":["..."],"openAssumptions":["..."]},"generatedFiles":[]}',
|
|
1585
|
+
"If the wish is underspecified, include concrete questions and open assumptions instead of inventing details.",
|
|
1586
|
+
"User wish:",
|
|
1587
|
+
wish_text,
|
|
1588
|
+
])
|
|
1589
|
+
if mission_id == "pre-release":
|
|
1590
|
+
validation_mode = "Run the project-defined full validation path before finalizing the report, and include actionable fixes for any failures." if run_full_validation else "Do not run full validation; identify the validation commands that should be run before release."
|
|
1591
|
+
release_prep_guidance = (
|
|
1592
|
+
"Prepare release metadata files for the requested version when needed: update package.json, pyproject.toml, VERSION, and create or update the matching changelogs/CHANGELOGS_X_Y_Z.md. Do not create Git tags, push branches, publish packages, upload release assets, or create GitHub releases."
|
|
1593
|
+
if allow_file_writes
|
|
1594
|
+
else "Do not modify package versions, changelog files, create Git tags, push branches, publish packages, upload release assets, or create GitHub releases."
|
|
1595
|
+
)
|
|
1596
|
+
return "\n".join([
|
|
1597
|
+
f"Prepare a guarded pre-release for version {release_version}.",
|
|
1598
|
+
validation_mode,
|
|
1599
|
+
release_prep_guidance,
|
|
1600
|
+
write_guidance,
|
|
1601
|
+
commit_guidance,
|
|
1602
|
+
"Return JSON only with this schema:",
|
|
1603
|
+
'{"summary":"...","version":"vX.X.X","validationMode":"full|plan-only","validationEvidence":["..."],"actionableFixes":[{"title":"...","command":"...","risk":"..."}],"generatedFiles":[{"path":"...","purpose":"..."}],"releasePlan":["..."],"blocked":false}',
|
|
1604
|
+
])
|
|
1605
|
+
raise ValueError("Unknown CDX mission.")
|
|
1606
|
+
|
|
1607
|
+
|
|
1608
|
+
def _cdx_mission_command(
|
|
1609
|
+
repo_root: Path,
|
|
1610
|
+
mission_id: str,
|
|
1611
|
+
*,
|
|
1612
|
+
session: str,
|
|
1613
|
+
strength: dict[str, Any],
|
|
1614
|
+
release_tag: str = "",
|
|
1615
|
+
mission_inputs: dict[str, str] | None = None,
|
|
1616
|
+
allow_file_writes: bool = False,
|
|
1617
|
+
commit_at_end: bool = False,
|
|
1618
|
+
) -> list[str]:
|
|
1619
|
+
mission_inputs = mission_inputs or {}
|
|
1620
|
+
prompt = _cdx_mission_prompt(
|
|
1621
|
+
mission_id,
|
|
1622
|
+
release_tag=release_tag,
|
|
1623
|
+
wish_text=mission_inputs.get("wishText", ""),
|
|
1624
|
+
release_version=mission_inputs.get("releaseVersion", ""),
|
|
1625
|
+
run_full_validation=mission_inputs.get("runFullValidation") == "true",
|
|
1626
|
+
allow_file_writes=allow_file_writes,
|
|
1627
|
+
direct_fixes=mission_inputs.get("directFixes") == "true",
|
|
1628
|
+
commit_at_end=commit_at_end,
|
|
1629
|
+
)
|
|
1630
|
+
timeout = int(strength.get("timeout") or 180)
|
|
1631
|
+
reasoning_effort = str(strength.get("reasoningEffort") or "medium")
|
|
1632
|
+
power = str(strength.get("power") or "medium")
|
|
1633
|
+
permission = "workspace-write" if allow_file_writes else "read-only"
|
|
1634
|
+
return [
|
|
1635
|
+
"run",
|
|
1636
|
+
session,
|
|
1637
|
+
"--cwd",
|
|
1638
|
+
str(repo_root),
|
|
1639
|
+
"--prompt",
|
|
1640
|
+
prompt,
|
|
1641
|
+
"--kind",
|
|
1642
|
+
"assistant",
|
|
1643
|
+
"--reasoning-effort",
|
|
1644
|
+
reasoning_effort,
|
|
1645
|
+
"--power",
|
|
1646
|
+
power,
|
|
1647
|
+
"--permission",
|
|
1648
|
+
permission,
|
|
1649
|
+
"--timeout-seconds",
|
|
1650
|
+
str(timeout),
|
|
1651
|
+
"--json",
|
|
1652
|
+
]
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _parse_json_from_text(text: str) -> dict[str, Any] | None:
|
|
1656
|
+
raw = text.strip()
|
|
1657
|
+
if not raw:
|
|
1658
|
+
return None
|
|
1659
|
+
jsonl_candidates: list[str] = []
|
|
1660
|
+
for line in reversed(raw.splitlines()):
|
|
1661
|
+
line = line.strip()
|
|
1662
|
+
if not line.startswith("{"):
|
|
1663
|
+
continue
|
|
1664
|
+
try:
|
|
1665
|
+
event = json.loads(line)
|
|
1666
|
+
except json.JSONDecodeError:
|
|
1667
|
+
continue
|
|
1668
|
+
if not isinstance(event, dict):
|
|
1669
|
+
continue
|
|
1670
|
+
item = event.get("item") if isinstance(event.get("item"), dict) else {}
|
|
1671
|
+
text_value = item.get("text") if item.get("type") == "agent_message" else event.get("text")
|
|
1672
|
+
if isinstance(text_value, str) and text_value.strip():
|
|
1673
|
+
jsonl_candidates.append(text_value.strip())
|
|
1674
|
+
candidates = [raw]
|
|
1675
|
+
candidates.extend(jsonl_candidates)
|
|
1676
|
+
fence_match = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL)
|
|
1677
|
+
if fence_match:
|
|
1678
|
+
candidates.insert(0, fence_match.group(1).strip())
|
|
1679
|
+
decoder = json.JSONDecoder()
|
|
1680
|
+
fallback: dict[str, Any] | None = None
|
|
1681
|
+
for candidate in candidates:
|
|
1682
|
+
try:
|
|
1683
|
+
parsed = json.loads(candidate)
|
|
1684
|
+
if isinstance(parsed, dict):
|
|
1685
|
+
if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
|
|
1686
|
+
return parsed
|
|
1687
|
+
fallback = fallback or parsed
|
|
1688
|
+
except json.JSONDecodeError:
|
|
1689
|
+
pass
|
|
1690
|
+
for index, char in enumerate(candidate):
|
|
1691
|
+
if char != "{":
|
|
1692
|
+
continue
|
|
1693
|
+
try:
|
|
1694
|
+
parsed, _end = decoder.raw_decode(candidate[index:])
|
|
1695
|
+
except json.JSONDecodeError:
|
|
1696
|
+
continue
|
|
1697
|
+
if isinstance(parsed, dict):
|
|
1698
|
+
if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
|
|
1699
|
+
return parsed
|
|
1700
|
+
fallback = fallback or parsed
|
|
1701
|
+
return fallback
|
|
1702
|
+
|
|
1703
|
+
|
|
1704
|
+
def _read_cdx_output_path(parsed: dict[str, Any]) -> str:
|
|
1705
|
+
candidates = [
|
|
1706
|
+
parsed.get("stdout"),
|
|
1707
|
+
parsed.get("output"),
|
|
1708
|
+
]
|
|
1709
|
+
artifacts = parsed.get("artifacts") if isinstance(parsed.get("artifacts"), dict) else {}
|
|
1710
|
+
candidates.extend([
|
|
1711
|
+
parsed.get("stdout_path"),
|
|
1712
|
+
parsed.get("stdoutPath"),
|
|
1713
|
+
artifacts.get("stdout_path"),
|
|
1714
|
+
artifacts.get("stdoutPath"),
|
|
1715
|
+
])
|
|
1716
|
+
for candidate in candidates:
|
|
1717
|
+
if not isinstance(candidate, str) or not candidate.strip():
|
|
1718
|
+
continue
|
|
1719
|
+
value = candidate.strip()
|
|
1720
|
+
if "\n" in value or value.lstrip().startswith("{") or value.lstrip().startswith("```"):
|
|
1721
|
+
return value[:12000]
|
|
1722
|
+
path = Path(value).expanduser()
|
|
1723
|
+
if not path.is_file():
|
|
1724
|
+
continue
|
|
1725
|
+
try:
|
|
1726
|
+
with path.open("rb") as handle:
|
|
1727
|
+
size = path.stat().st_size
|
|
1728
|
+
if size > 60000:
|
|
1729
|
+
handle.seek(size - 60000)
|
|
1730
|
+
return handle.read(60000).decode("utf-8", errors="replace")
|
|
1731
|
+
except OSError:
|
|
1732
|
+
continue
|
|
1733
|
+
return ""
|
|
1734
|
+
|
|
1735
|
+
|
|
1736
|
+
def _merge_cdx_mission_output(parsed: Any) -> dict[str, Any] | None:
|
|
1737
|
+
if not isinstance(parsed, dict):
|
|
1738
|
+
return None
|
|
1739
|
+
merged = dict(parsed)
|
|
1740
|
+
embedded = _parse_json_from_text(_read_cdx_output_path(parsed))
|
|
1741
|
+
if embedded:
|
|
1742
|
+
merged["missionOutput"] = embedded
|
|
1743
|
+
if isinstance(embedded.get("actions"), list) and "actions" not in merged:
|
|
1744
|
+
merged["actions"] = embedded["actions"]
|
|
1745
|
+
if "summary" in embedded and "summary" not in merged:
|
|
1746
|
+
merged["summary"] = embedded["summary"]
|
|
1747
|
+
return merged
|
|
1748
|
+
|
|
1749
|
+
|
|
1750
|
+
def _extract_cdx_usage(parsed: Any) -> dict[str, Any]:
|
|
1751
|
+
if not isinstance(parsed, dict):
|
|
1752
|
+
return {"available": False, "message": "CDX did not return structured usage."}
|
|
1753
|
+
candidates = [
|
|
1754
|
+
parsed.get("usage"),
|
|
1755
|
+
parsed.get("tokenUsage"),
|
|
1756
|
+
parsed.get("tokens"),
|
|
1757
|
+
(parsed.get("run") or {}).get("usage") if isinstance(parsed.get("run"), dict) else None,
|
|
1758
|
+
(parsed.get("result") or {}).get("usage") if isinstance(parsed.get("result"), dict) else None,
|
|
1759
|
+
]
|
|
1760
|
+
usage = next((candidate for candidate in candidates if isinstance(candidate, dict)), None)
|
|
1761
|
+
if usage is None:
|
|
1762
|
+
return {"available": False, "message": "Token usage was not exposed by CDX for this run."}
|
|
1763
|
+
input_tokens = usage.get("input_tokens", usage.get("inputTokens", usage.get("prompt_tokens", usage.get("promptTokens"))))
|
|
1764
|
+
output_tokens = usage.get("output_tokens", usage.get("outputTokens", usage.get("completion_tokens", usage.get("completionTokens"))))
|
|
1765
|
+
total_tokens = usage.get("total_tokens", usage.get("totalTokens"))
|
|
1766
|
+
if total_tokens is None and isinstance(input_tokens, int) and isinstance(output_tokens, int):
|
|
1767
|
+
total_tokens = input_tokens + output_tokens
|
|
1768
|
+
return {
|
|
1769
|
+
"available": True,
|
|
1770
|
+
"inputTokens": input_tokens,
|
|
1771
|
+
"outputTokens": output_tokens,
|
|
1772
|
+
"totalTokens": total_tokens,
|
|
1773
|
+
"raw": usage,
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
|
|
1777
|
+
def _bounded_process_text(value: str, limit: int = 12000) -> str:
|
|
1778
|
+
text = value.strip()
|
|
1779
|
+
if len(text) <= limit:
|
|
1780
|
+
return text
|
|
1781
|
+
return f"{text[:limit]}\n... truncated ..."
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def cdx_mission_plan_payload(
|
|
1785
|
+
repo_root: Path,
|
|
1786
|
+
body: dict[str, Any],
|
|
1787
|
+
*,
|
|
1788
|
+
cdx_runner: Any | None = None,
|
|
1789
|
+
git_runner: Any | None = None,
|
|
1790
|
+
which: Any | None = None,
|
|
1791
|
+
) -> dict[str, Any]:
|
|
1792
|
+
tool_which = which or shutil.which
|
|
1793
|
+
if not tool_which("cdx"):
|
|
1794
|
+
return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "plan": None}
|
|
1795
|
+
mission_id = str(body.get("missionId") or CDX_DEFAULT_MISSION_ID)
|
|
1796
|
+
mission = CDX_MISSION_CATALOG.get(mission_id)
|
|
1797
|
+
if mission is None:
|
|
1798
|
+
return {"state": "error", "message": "Unknown CDX mission.", "plan": None}
|
|
1799
|
+
strength = str(body.get("strengthId") or "standard")
|
|
1800
|
+
strength_def = CDX_MISSION_STRENGTHS.get(strength)
|
|
1801
|
+
if strength_def is None:
|
|
1802
|
+
return {"state": "error", "message": "Unknown CDX mission strength.", "plan": None}
|
|
1803
|
+
|
|
1804
|
+
status_payload = cdx_status_payload(repo_root, runner=cdx_runner, which=which)
|
|
1805
|
+
session = _normalize_cdx_session(body.get("sessionId"), status_payload if status_payload.get("state") == "ok" else None)
|
|
1806
|
+
if not session:
|
|
1807
|
+
sessions = _cdx_status_sessions(status_payload)
|
|
1808
|
+
session = sessions[0] if sessions else ""
|
|
1809
|
+
if not session:
|
|
1810
|
+
return {"state": "error", "message": "No usable CDX session is available.", "plan": None, "status": status_payload}
|
|
1811
|
+
|
|
1812
|
+
release_tag = ""
|
|
1813
|
+
warnings: list[str] = []
|
|
1814
|
+
mission_inputs: dict[str, str] = {}
|
|
1815
|
+
if mission_id == "wish-to-request":
|
|
1816
|
+
wish_text = _mission_text_input(body, "wishText")
|
|
1817
|
+
if not wish_text:
|
|
1818
|
+
return {"state": "error", "message": "Enter a wish or intent before previewing this mission.", "plan": None, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
|
|
1819
|
+
mission_inputs["wishText"] = wish_text
|
|
1820
|
+
if mission_id in {"full-audit", "release-review"}:
|
|
1821
|
+
mission_inputs["directFixes"] = "true" if _mission_bool_input(body, "directFixes") else "false"
|
|
1822
|
+
if mission_id == "pre-release":
|
|
1823
|
+
release_version = _mission_text_input(body, "releaseVersion", max_chars=40)
|
|
1824
|
+
if not re.fullmatch(r"v\d+\.\d+\.\d+", release_version):
|
|
1825
|
+
return {"state": "error", "message": "Enter a semantic version in vX.X.X format before previewing this mission.", "plan": None, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
|
|
1826
|
+
mission_inputs["releaseVersion"] = release_version
|
|
1827
|
+
mission_inputs["runFullValidation"] = "true" if _mission_bool_input(body, "runFullValidation") else "false"
|
|
1828
|
+
if mission.get("requiresReleaseTag"):
|
|
1829
|
+
release_tag = _latest_release_tag(repo_root, runner=git_runner, which=which)
|
|
1830
|
+
if not release_tag:
|
|
1831
|
+
return {"state": "error", "message": "No release tag was found for this mission.", "plan": None, "status": status_payload}
|
|
1832
|
+
if status_payload.get("state") != "ok":
|
|
1833
|
+
warnings.append(str(status_payload.get("message") or "CDX status could not be confirmed."))
|
|
1834
|
+
|
|
1835
|
+
requested_file_writes = _mission_bool_input(body, "allowFileWrites")
|
|
1836
|
+
requested_commit_at_end = _mission_bool_input(body, "commitAtEnd")
|
|
1837
|
+
direct_fixes = mission_inputs.get("directFixes") == "true"
|
|
1838
|
+
supports_file_writes = bool(mission.get("supportsFileWrites", True))
|
|
1839
|
+
allow_file_writes = (requested_file_writes or direct_fixes) and supports_file_writes
|
|
1840
|
+
commit_at_end = requested_commit_at_end and allow_file_writes
|
|
1841
|
+
if requested_file_writes and not supports_file_writes:
|
|
1842
|
+
warnings.append("This mission is plan-first; direct CDX file writes are disabled. Use Apply allowed actions after CDX returns actions.")
|
|
1843
|
+
if requested_commit_at_end and not allow_file_writes:
|
|
1844
|
+
warnings.append("Commit-at-end was requested but direct file writes are disabled for this mission.")
|
|
1845
|
+
permission = "workspace-write" if allow_file_writes else "read-only"
|
|
1846
|
+
command = _cdx_mission_command(
|
|
1847
|
+
repo_root,
|
|
1848
|
+
mission_id,
|
|
1849
|
+
session=session,
|
|
1850
|
+
strength=strength_def,
|
|
1851
|
+
release_tag=release_tag,
|
|
1852
|
+
mission_inputs=mission_inputs,
|
|
1853
|
+
allow_file_writes=allow_file_writes,
|
|
1854
|
+
commit_at_end=commit_at_end,
|
|
1855
|
+
)
|
|
1856
|
+
plan = {
|
|
1857
|
+
"mission": mission,
|
|
1858
|
+
"missionId": mission_id,
|
|
1859
|
+
"sessionId": session,
|
|
1860
|
+
"strength": strength_def,
|
|
1861
|
+
"strengthId": strength,
|
|
1862
|
+
"missionInputs": mission_inputs,
|
|
1863
|
+
"scope": mission["scope"],
|
|
1864
|
+
"releaseTag": release_tag,
|
|
1865
|
+
"allowFileWrites": allow_file_writes,
|
|
1866
|
+
"requestedFileWrites": requested_file_writes,
|
|
1867
|
+
"commitAtEnd": commit_at_end,
|
|
1868
|
+
"requestedCommitAtEnd": requested_commit_at_end,
|
|
1869
|
+
"supportsFileWrites": supports_file_writes,
|
|
1870
|
+
"permission": permission,
|
|
1871
|
+
"command": ["cdx", *command],
|
|
1872
|
+
"arguments": command,
|
|
1873
|
+
"warnings": warnings,
|
|
1874
|
+
"requiresConfirmation": bool(mission.get("requiresPlanConfirmation")),
|
|
1875
|
+
"canRun": True,
|
|
1876
|
+
}
|
|
1877
|
+
if mission_id == "corpus-ready":
|
|
1878
|
+
plan["allowedPlanActions"] = [
|
|
1879
|
+
"promote-request-to-backlog",
|
|
1880
|
+
"promote-backlog-to-task",
|
|
1881
|
+
"refresh-corpus-context",
|
|
1882
|
+
]
|
|
1883
|
+
return {"state": "ok", "message": "", "plan": plan, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
|
|
1884
|
+
|
|
1885
|
+
|
|
1886
|
+
def cdx_mission_run_payload(
|
|
1887
|
+
repo_root: Path,
|
|
1888
|
+
body: dict[str, Any],
|
|
1889
|
+
*,
|
|
1890
|
+
cdx_runner: Any | None = None,
|
|
1891
|
+
git_runner: Any | None = None,
|
|
1892
|
+
which: Any | None = None,
|
|
1893
|
+
) -> dict[str, Any]:
|
|
1894
|
+
plan_payload = cdx_mission_plan_payload(repo_root, body, cdx_runner=cdx_runner, git_runner=git_runner, which=which)
|
|
1895
|
+
if plan_payload.get("state") != "ok":
|
|
1896
|
+
return {"state": plan_payload.get("state") or "error", "message": plan_payload.get("message") or "Unable to plan CDX mission.", "plan": plan_payload.get("plan"), "run": None}
|
|
1897
|
+
plan = plan_payload["plan"]
|
|
1898
|
+
timeout = int(plan["strength"].get("timeout") or 180)
|
|
1899
|
+
try:
|
|
1900
|
+
result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=timeout, runner=cdx_runner)
|
|
1901
|
+
except subprocess.TimeoutExpired:
|
|
1902
|
+
return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
|
|
1903
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1904
|
+
return {"state": "error", "message": f"Unable to run CDX mission: {exc}", "plan": plan, "run": None}
|
|
1905
|
+
|
|
1906
|
+
parsed: Any = None
|
|
1907
|
+
if result.stdout.strip():
|
|
1908
|
+
try:
|
|
1909
|
+
parsed = json.loads(result.stdout)
|
|
1910
|
+
except json.JSONDecodeError:
|
|
1911
|
+
parsed = None
|
|
1912
|
+
parsed = _merge_cdx_mission_output(parsed)
|
|
1913
|
+
usage = _extract_cdx_usage(parsed)
|
|
1914
|
+
run_id = ""
|
|
1915
|
+
if isinstance(parsed, dict):
|
|
1916
|
+
run = parsed.get("run") if isinstance(parsed.get("run"), dict) else {}
|
|
1917
|
+
run_id = str(parsed.get("run_id") or parsed.get("runId") or run.get("run_id") or run.get("runId") or "")
|
|
1918
|
+
run_payload = {
|
|
1919
|
+
"returnCode": result.returncode,
|
|
1920
|
+
"runId": run_id,
|
|
1921
|
+
"stdout": _bounded_process_text(result.stdout or ""),
|
|
1922
|
+
"stderr": _bounded_process_text(result.stderr or ""),
|
|
1923
|
+
"parsed": parsed if isinstance(parsed, dict) else None,
|
|
1924
|
+
"usage": usage,
|
|
1925
|
+
}
|
|
1926
|
+
if result.returncode != 0:
|
|
1927
|
+
message = (result.stderr or result.stdout or "CDX mission failed.").strip().splitlines()[0]
|
|
1928
|
+
return {"state": "error", "message": message, "plan": plan, "run": run_payload}
|
|
1929
|
+
return {"state": "ok", "message": "", "plan": plan, "run": run_payload}
|
|
1930
|
+
|
|
1931
|
+
|
|
1932
|
+
def cdx_mission_apply_plan_payload(repo_root: Path, body: dict[str, Any], *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
1933
|
+
tool_which = which or shutil.which
|
|
1934
|
+
if not tool_which("logics-manager"):
|
|
1935
|
+
return {"state": "unavailable", "message": "logics-manager executable is not available on PATH.", "results": []}
|
|
1936
|
+
actions = body.get("actions") if isinstance(body.get("actions"), list) else []
|
|
1937
|
+
if not actions:
|
|
1938
|
+
return {"state": "error", "message": "No corpus plan actions were provided.", "results": []}
|
|
1939
|
+
|
|
1940
|
+
allowed: dict[str, list[str]] = {
|
|
1941
|
+
"promote-request-to-backlog": ["flow", "promote", "request-to-backlog"],
|
|
1942
|
+
"promote-backlog-to-task": ["flow", "promote", "backlog-to-task"],
|
|
1943
|
+
"refresh-corpus-context": ["sync", "refresh-mermaid-signatures"],
|
|
1944
|
+
}
|
|
1945
|
+
results: list[dict[str, Any]] = []
|
|
1946
|
+
for action in actions:
|
|
1947
|
+
if not isinstance(action, dict):
|
|
1948
|
+
return {"state": "error", "message": "Corpus plan actions must be objects.", "results": results}
|
|
1949
|
+
action_type = str(action.get("type") or "")
|
|
1950
|
+
command = allowed.get(action_type)
|
|
1951
|
+
if command is None:
|
|
1952
|
+
return {"state": "error", "message": f"Unsupported corpus plan action: {action_type}", "results": results}
|
|
1953
|
+
target = str(action.get("target") or "").strip()
|
|
1954
|
+
args = [*command]
|
|
1955
|
+
if target and action_type != "refresh-corpus-context":
|
|
1956
|
+
if not re.match(r"^[A-Za-z0-9_.:/-]{1,160}$", target):
|
|
1957
|
+
return {"state": "error", "message": "Invalid corpus plan action target.", "results": results}
|
|
1958
|
+
args.append(target)
|
|
1959
|
+
try:
|
|
1960
|
+
result = _run_logics_command(repo_root, args, runner=runner)
|
|
1961
|
+
except subprocess.TimeoutExpired:
|
|
1962
|
+
return {"state": "timeout", "message": "Logics corpus plan application timed out.", "results": results}
|
|
1963
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1964
|
+
return {"state": "error", "message": f"Unable to apply corpus plan action: {exc}", "results": results}
|
|
1965
|
+
item = {
|
|
1966
|
+
"type": action_type,
|
|
1967
|
+
"target": target,
|
|
1968
|
+
"command": ["logics-manager", *args],
|
|
1969
|
+
"returnCode": result.returncode,
|
|
1970
|
+
"stdout": _bounded_process_text(result.stdout or "", 4000),
|
|
1971
|
+
"stderr": _bounded_process_text(result.stderr or "", 4000),
|
|
1972
|
+
}
|
|
1973
|
+
results.append(item)
|
|
1974
|
+
if result.returncode != 0:
|
|
1975
|
+
message = (result.stderr or result.stdout or "Corpus plan action failed.").strip().splitlines()[0]
|
|
1976
|
+
return {"state": "error", "message": message, "results": results}
|
|
1977
|
+
return {"state": "ok", "message": "", "results": results}
|
|
1978
|
+
|
|
1979
|
+
|
|
1147
1980
|
def _slugify_viewer_doc(text: str) -> str:
|
|
1148
1981
|
slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
|
1149
1982
|
return slug[:80] or "cdx_code_review_findings"
|
|
@@ -1164,17 +1997,68 @@ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, An
|
|
|
1164
1997
|
report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
|
|
1165
1998
|
run = report.get("run") if isinstance(report.get("run"), dict) else {}
|
|
1166
1999
|
task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
|
|
2000
|
+
parsed = report.get("parsed") if isinstance(report.get("parsed"), dict) else {}
|
|
2001
|
+
mission_output = next(
|
|
2002
|
+
(
|
|
2003
|
+
candidate
|
|
2004
|
+
for candidate in (
|
|
2005
|
+
report.get("missionOutput"),
|
|
2006
|
+
report.get("mission_output"),
|
|
2007
|
+
parsed.get("missionOutput"),
|
|
2008
|
+
parsed.get("mission_output"),
|
|
2009
|
+
run.get("missionOutput"),
|
|
2010
|
+
run.get("mission_output"),
|
|
2011
|
+
task_report.get("missionOutput"),
|
|
2012
|
+
task_report.get("mission_output"),
|
|
2013
|
+
)
|
|
2014
|
+
if isinstance(candidate, dict)
|
|
2015
|
+
),
|
|
2016
|
+
{},
|
|
2017
|
+
)
|
|
1167
2018
|
run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
|
|
2019
|
+
task_kind = str(task_report.get("kind") or run.get("kind") or "assistant")
|
|
1168
2020
|
findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
|
|
1169
|
-
|
|
2021
|
+
if not findings and isinstance(mission_output.get("findings"), list):
|
|
2022
|
+
findings = mission_output["findings"]
|
|
2023
|
+
recommendations = mission_output.get("recommendations") if isinstance(mission_output.get("recommendations"), list) else []
|
|
2024
|
+
request_files = mission_output.get("requestFiles") if isinstance(mission_output.get("requestFiles"), list) else []
|
|
2025
|
+
actionable_fixes = mission_output.get("actionableFixes") if isinstance(mission_output.get("actionableFixes"), list) else []
|
|
2026
|
+
release_plan = mission_output.get("releasePlan") if isinstance(mission_output.get("releasePlan"), list) else []
|
|
2027
|
+
if task_kind == "code-review":
|
|
2028
|
+
title = f"Address CDX code review findings for {run_id}"
|
|
2029
|
+
theme = "Code review follow-up"
|
|
2030
|
+
need = f"Follow up on CDX code-review run `{run_id}`."
|
|
2031
|
+
elif task_kind == "full-audit":
|
|
2032
|
+
title = f"Address CDX audit findings for {run_id}"
|
|
2033
|
+
theme = "Audit follow-up"
|
|
2034
|
+
need = f"Follow up on CDX full-audit run `{run_id}`."
|
|
2035
|
+
else:
|
|
2036
|
+
title = f"Address CDX {task_kind} follow-up for {run_id}"
|
|
2037
|
+
theme = "CDX mission follow-up"
|
|
2038
|
+
need = f"Follow up on CDX `{task_kind}` run `{run_id}`."
|
|
1170
2039
|
ref = _next_viewer_request_ref(repo_root, title)
|
|
1171
2040
|
request_dir = repo_root / "logics" / "request"
|
|
1172
2041
|
request_dir.mkdir(parents=True, exist_ok=True)
|
|
1173
2042
|
rel_path = f"logics/request/{ref}.md"
|
|
1174
2043
|
path = repo_root / rel_path
|
|
2044
|
+
|
|
2045
|
+
def _item_message(item: Any, fallback: str) -> str:
|
|
2046
|
+
if isinstance(item, dict):
|
|
2047
|
+
title_value = item.get("title") or item.get("message") or item.get("summary") or item.get("path") or fallback
|
|
2048
|
+
details = []
|
|
2049
|
+
if item.get("purpose"):
|
|
2050
|
+
details.append(f"purpose: {item['purpose']}")
|
|
2051
|
+
if item.get("command"):
|
|
2052
|
+
details.append(f"command: `{item['command']}`")
|
|
2053
|
+
if item.get("risk"):
|
|
2054
|
+
details.append(f"risk: {item['risk']}")
|
|
2055
|
+
return f"{title_value}" + (f" ({'; '.join(details)})" if details else "")
|
|
2056
|
+
return str(item or fallback)
|
|
2057
|
+
|
|
1175
2058
|
finding_lines = []
|
|
1176
2059
|
for index, finding in enumerate(findings, start=1):
|
|
1177
2060
|
if not isinstance(finding, dict):
|
|
2061
|
+
finding_lines.append(f"- F{index}: {finding}")
|
|
1178
2062
|
continue
|
|
1179
2063
|
location = finding.get("path") or finding.get("file") or "unknown path"
|
|
1180
2064
|
if finding.get("line"):
|
|
@@ -1184,21 +2068,36 @@ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, An
|
|
|
1184
2068
|
finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
|
|
1185
2069
|
if not finding_lines:
|
|
1186
2070
|
finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
|
|
2071
|
+
follow_up_lines = []
|
|
2072
|
+
for label, values in (
|
|
2073
|
+
("Recommendation", recommendations),
|
|
2074
|
+
("Request file", request_files),
|
|
2075
|
+
("Actionable fix", actionable_fixes),
|
|
2076
|
+
("Release plan", release_plan),
|
|
2077
|
+
):
|
|
2078
|
+
for index, value in enumerate(values, start=1):
|
|
2079
|
+
follow_up_lines.append(f"- {label} {index}: {_item_message(value, label)}")
|
|
2080
|
+
if not follow_up_lines:
|
|
2081
|
+
follow_up_lines.append("- Review CDX output and split any actionable follow-up into tasks before implementation.")
|
|
2082
|
+
summary = task_report.get("summary") or mission_output.get("summary") or "No structured summary provided."
|
|
1187
2083
|
text = "\n".join([
|
|
1188
2084
|
f"## {ref} - {title}",
|
|
1189
2085
|
"> Status: Draft",
|
|
1190
2086
|
"> Understanding: 70%",
|
|
1191
2087
|
"> Confidence: 70%",
|
|
1192
2088
|
"> Complexity: Medium",
|
|
1193
|
-
"> Theme:
|
|
2089
|
+
f"> Theme: {theme}",
|
|
1194
2090
|
"",
|
|
1195
2091
|
"# Needs",
|
|
1196
|
-
f"-
|
|
1197
|
-
f"- Summary: {
|
|
2092
|
+
f"- {need}",
|
|
2093
|
+
f"- Summary: {summary}",
|
|
1198
2094
|
"",
|
|
1199
2095
|
"# Findings",
|
|
1200
2096
|
*finding_lines,
|
|
1201
2097
|
"",
|
|
2098
|
+
"# Follow-up",
|
|
2099
|
+
*follow_up_lines,
|
|
2100
|
+
"",
|
|
1202
2101
|
"# Traceability",
|
|
1203
2102
|
f"- CDX run id: `{run_id}`",
|
|
1204
2103
|
f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
|
|
@@ -1362,6 +2261,11 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1362
2261
|
cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
|
|
1363
2262
|
self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
|
|
1364
2263
|
return
|
|
2264
|
+
if route == "/api/git-file-preview":
|
|
2265
|
+
params = parse_qs(parsed.query)
|
|
2266
|
+
rel_path = params.get("path", [""])[0]
|
|
2267
|
+
self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
|
|
2268
|
+
return
|
|
1365
2269
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
1366
2270
|
|
|
1367
2271
|
def do_POST(self) -> None:
|
|
@@ -1413,6 +2317,33 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1413
2317
|
except OSError as exc:
|
|
1414
2318
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1415
2319
|
return
|
|
2320
|
+
if parsed.path == "/api/cdx-mission-plan":
|
|
2321
|
+
try:
|
|
2322
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2323
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2324
|
+
body = json.loads(raw_body or "{}")
|
|
2325
|
+
self._send_json({"ok": True, "payload": cdx_mission_plan_payload(self.server.repo_root, body)})
|
|
2326
|
+
except json.JSONDecodeError:
|
|
2327
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2328
|
+
return
|
|
2329
|
+
if parsed.path == "/api/cdx-mission-run":
|
|
2330
|
+
try:
|
|
2331
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2332
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2333
|
+
body = json.loads(raw_body or "{}")
|
|
2334
|
+
self._send_json({"ok": True, "payload": cdx_mission_run_payload(self.server.repo_root, body)})
|
|
2335
|
+
except json.JSONDecodeError:
|
|
2336
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2337
|
+
return
|
|
2338
|
+
if parsed.path == "/api/cdx-mission-apply-plan":
|
|
2339
|
+
try:
|
|
2340
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2341
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2342
|
+
body = json.loads(raw_body or "{}")
|
|
2343
|
+
self._send_json({"ok": True, "payload": cdx_mission_apply_plan_payload(self.server.repo_root, body)})
|
|
2344
|
+
except json.JSONDecodeError:
|
|
2345
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2346
|
+
return
|
|
1416
2347
|
if parsed.path == "/api/edit":
|
|
1417
2348
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
1418
2349
|
try:
|
|
@@ -1422,6 +2353,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1422
2353
|
except OSError as exc:
|
|
1423
2354
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1424
2355
|
return
|
|
2356
|
+
if parsed.path == "/api/open-file":
|
|
2357
|
+
try:
|
|
2358
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2359
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2360
|
+
body = json.loads(raw_body or "{}")
|
|
2361
|
+
self._send_json({"ok": True, "payload": open_file_payload(self.server.repo_root, str(body.get("path", "")))})
|
|
2362
|
+
except json.JSONDecodeError:
|
|
2363
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2364
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2365
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2366
|
+
except OSError as exc:
|
|
2367
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
2368
|
+
return
|
|
2369
|
+
if parsed.path == "/api/file-preview":
|
|
2370
|
+
try:
|
|
2371
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2372
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2373
|
+
body = json.loads(raw_body or "{}")
|
|
2374
|
+
self._send_json({"ok": True, "payload": file_preview_payload(self.server.repo_root, str(body.get("path", "")))})
|
|
2375
|
+
except json.JSONDecodeError:
|
|
2376
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2377
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2378
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2379
|
+
except OSError as exc:
|
|
2380
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
2381
|
+
return
|
|
1425
2382
|
if parsed.path == "/api/open-repo-folder":
|
|
1426
2383
|
try:
|
|
1427
2384
|
self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
|