@grifhinz/logics-manager 2.7.0 → 2.8.1
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 +59 -10
- package/VERSION +1 -1
- package/clients/viewer/browser-host.js +1620 -134
- package/clients/viewer/index.html +2 -0
- package/clients/viewer/viewer.css +542 -1
- package/logics_manager/cli.py +22 -20
- package/logics_manager/flow.py +61 -1
- package/logics_manager/sync.py +47 -19
- package/logics_manager/viewer.py +1206 -20
- package/package.json +2 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -44,6 +44,126 @@ 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_PARENT_TIMEOUT_GRACE_SECONDS = 90
|
|
53
|
+
CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS = 600
|
|
54
|
+
CDX_MISSION_CATALOG = {
|
|
55
|
+
"full-audit": {
|
|
56
|
+
"id": "full-audit",
|
|
57
|
+
"title": "Full audit",
|
|
58
|
+
"description": "Audit the repository and optionally apply safe, validated fixes.",
|
|
59
|
+
"scope": "repository",
|
|
60
|
+
"requiresReleaseTag": False,
|
|
61
|
+
"requiresPlanConfirmation": False,
|
|
62
|
+
"supportsFileWrites": True,
|
|
63
|
+
"inputFields": [
|
|
64
|
+
{
|
|
65
|
+
"id": "directFixes",
|
|
66
|
+
"label": "Fix directly",
|
|
67
|
+
"type": "checkbox",
|
|
68
|
+
"required": False,
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
},
|
|
72
|
+
"release-review": {
|
|
73
|
+
"id": "release-review",
|
|
74
|
+
"title": "Review since latest release",
|
|
75
|
+
"description": "Review changes since the latest release and optionally apply safe fixes.",
|
|
76
|
+
"scope": "latest-release",
|
|
77
|
+
"requiresReleaseTag": True,
|
|
78
|
+
"requiresPlanConfirmation": False,
|
|
79
|
+
"supportsFileWrites": True,
|
|
80
|
+
"inputFields": [
|
|
81
|
+
{
|
|
82
|
+
"id": "directFixes",
|
|
83
|
+
"label": "Fix directly",
|
|
84
|
+
"type": "checkbox",
|
|
85
|
+
"required": False,
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
"corpus-ready": {
|
|
90
|
+
"id": "corpus-ready",
|
|
91
|
+
"title": "Prepare dev-ready corpus",
|
|
92
|
+
"description": "Produce a corpus plan for explicit deterministic application.",
|
|
93
|
+
"scope": "open-logics-workflow",
|
|
94
|
+
"requiresReleaseTag": False,
|
|
95
|
+
"requiresPlanConfirmation": True,
|
|
96
|
+
"supportsFileWrites": False,
|
|
97
|
+
},
|
|
98
|
+
"wish-to-request": {
|
|
99
|
+
"id": "wish-to-request",
|
|
100
|
+
"title": "Wish to request",
|
|
101
|
+
"description": "Create or draft a structured Logics request from a free-form wish.",
|
|
102
|
+
"scope": "request-draft",
|
|
103
|
+
"requiresReleaseTag": False,
|
|
104
|
+
"requiresPlanConfirmation": False,
|
|
105
|
+
"supportsFileWrites": True,
|
|
106
|
+
"inputFields": [
|
|
107
|
+
{
|
|
108
|
+
"id": "wishText",
|
|
109
|
+
"label": "Wish or intent",
|
|
110
|
+
"type": "textarea",
|
|
111
|
+
"placeholder": "Describe the workflow, feature, bug, or product intent to capture.",
|
|
112
|
+
"required": True,
|
|
113
|
+
}
|
|
114
|
+
],
|
|
115
|
+
},
|
|
116
|
+
"pre-release": {
|
|
117
|
+
"id": "pre-release",
|
|
118
|
+
"title": "Guarded pre-release",
|
|
119
|
+
"description": "Prepare release metadata, changelog, validation, and fixes without tagging or publishing.",
|
|
120
|
+
"scope": "pre-release-report",
|
|
121
|
+
"requiresReleaseTag": False,
|
|
122
|
+
"requiresPlanConfirmation": False,
|
|
123
|
+
"supportsFileWrites": True,
|
|
124
|
+
"inputFields": [
|
|
125
|
+
{
|
|
126
|
+
"id": "releaseVersion",
|
|
127
|
+
"label": "Version",
|
|
128
|
+
"type": "text",
|
|
129
|
+
"placeholder": "vX.X.X",
|
|
130
|
+
"required": True,
|
|
131
|
+
"pattern": "^v\\d+\\.\\d+\\.\\d+$",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
"id": "runFullValidation",
|
|
135
|
+
"label": "Run full validation and report fixes before pre-release",
|
|
136
|
+
"type": "checkbox",
|
|
137
|
+
"required": False,
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
CDX_DEFAULT_MISSION_ID = "full-audit"
|
|
143
|
+
GIT_FILE_PREVIEW_MAX_BYTES = 30000
|
|
144
|
+
GIT_FILE_PREVIEW_MAX_CHARS = 20000
|
|
145
|
+
FILE_PREVIEW_MAX_BYTES = 300000
|
|
146
|
+
FILE_PREVIEW_MAX_CHARS = 200000
|
|
147
|
+
WORKSPACE_TREE_MAX_ENTRIES = 250
|
|
148
|
+
WORKSPACE_PREVIEW_MAX_BYTES = 30000
|
|
149
|
+
WORKSPACE_PREVIEW_MAX_CHARS = 20000
|
|
150
|
+
WORKSPACE_IGNORED_DIRS = {
|
|
151
|
+
".git",
|
|
152
|
+
".hg",
|
|
153
|
+
".svn",
|
|
154
|
+
"__pycache__",
|
|
155
|
+
".pytest_cache",
|
|
156
|
+
".mypy_cache",
|
|
157
|
+
".ruff_cache",
|
|
158
|
+
"node_modules",
|
|
159
|
+
"dist",
|
|
160
|
+
"build",
|
|
161
|
+
"coverage",
|
|
162
|
+
".next",
|
|
163
|
+
".turbo",
|
|
164
|
+
".venv",
|
|
165
|
+
"venv",
|
|
166
|
+
}
|
|
47
167
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
48
168
|
PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
|
|
49
169
|
VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
|
|
@@ -344,6 +464,7 @@ def viewer_data_payload(
|
|
|
344
464
|
selected_id: str | None = None,
|
|
345
465
|
*,
|
|
346
466
|
auto_refresh_interval_seconds: int = 15,
|
|
467
|
+
auto_refresh_interval_forced: bool = False,
|
|
347
468
|
projects: list[dict[str, Any]] | None = None,
|
|
348
469
|
) -> dict[str, Any]:
|
|
349
470
|
capabilities = viewer_project_capabilities(repo_root)
|
|
@@ -359,6 +480,7 @@ def viewer_data_payload(
|
|
|
359
480
|
"capabilities": capabilities,
|
|
360
481
|
"projects": projects if projects is not None else viewer_project_registry(repo_root),
|
|
361
482
|
"autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
|
|
483
|
+
"autoRefreshIntervalForced": auto_refresh_interval_forced,
|
|
362
484
|
"items": collect_viewer_items(repo_root),
|
|
363
485
|
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
364
486
|
"selectedId": selected_id,
|
|
@@ -513,9 +635,16 @@ def viewer_project_capabilities(
|
|
|
513
635
|
else:
|
|
514
636
|
cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
|
|
515
637
|
cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
|
|
638
|
+
workspace = _viewer_capability(
|
|
639
|
+
"ready" if repo_root.is_dir() else "missing",
|
|
640
|
+
available=repo_root.is_dir(),
|
|
641
|
+
message="Workspace root can be inspected." if repo_root.is_dir() else "Workspace root is unavailable.",
|
|
642
|
+
detail={"root": str(repo_root.resolve())} if repo_root.is_dir() else {},
|
|
643
|
+
)
|
|
516
644
|
|
|
517
645
|
return {
|
|
518
646
|
"logics": logics,
|
|
647
|
+
"workspace": workspace,
|
|
519
648
|
"git": git,
|
|
520
649
|
"ci": ci,
|
|
521
650
|
"cdx": cdx,
|
|
@@ -553,6 +682,54 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
|
|
|
553
682
|
}
|
|
554
683
|
|
|
555
684
|
|
|
685
|
+
def _resolve_openable_file_path(repo_root: Path, file_path: str) -> Path:
|
|
686
|
+
raw_path = unquote(file_path).strip()
|
|
687
|
+
if not raw_path:
|
|
688
|
+
raise ValueError("Missing file path.")
|
|
689
|
+
candidate = Path(raw_path).expanduser()
|
|
690
|
+
if not candidate.is_absolute():
|
|
691
|
+
candidate = repo_root / raw_path.lstrip("/\\")
|
|
692
|
+
absolute = candidate.resolve()
|
|
693
|
+
if not absolute.is_file():
|
|
694
|
+
raise FileNotFoundError(str(candidate))
|
|
695
|
+
return absolute
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def open_file_payload(repo_root: Path, file_path: str, *, launcher: Any | None = None) -> dict[str, str]:
|
|
699
|
+
absolute = _resolve_openable_file_path(repo_root, file_path)
|
|
700
|
+
command = _system_editor_command(absolute)
|
|
701
|
+
runner = launcher or subprocess.Popen
|
|
702
|
+
runner(command)
|
|
703
|
+
return {
|
|
704
|
+
"path": str(absolute),
|
|
705
|
+
"command": command[0],
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def file_preview_payload(
|
|
710
|
+
repo_root: Path,
|
|
711
|
+
file_path: str,
|
|
712
|
+
*,
|
|
713
|
+
max_bytes: int = FILE_PREVIEW_MAX_BYTES,
|
|
714
|
+
max_chars: int = FILE_PREVIEW_MAX_CHARS,
|
|
715
|
+
) -> dict[str, Any]:
|
|
716
|
+
absolute = _resolve_openable_file_path(repo_root, file_path)
|
|
717
|
+
raw = absolute.read_bytes()
|
|
718
|
+
truncated = len(raw) > max_bytes
|
|
719
|
+
if truncated:
|
|
720
|
+
raw = raw[-max_bytes:]
|
|
721
|
+
content = raw.decode("utf-8", errors="replace")
|
|
722
|
+
if len(content) > max_chars:
|
|
723
|
+
content = content[-max_chars:]
|
|
724
|
+
truncated = True
|
|
725
|
+
return {
|
|
726
|
+
"path": str(absolute),
|
|
727
|
+
"name": absolute.name,
|
|
728
|
+
"content": content,
|
|
729
|
+
"truncated": truncated,
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
|
|
556
733
|
def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
|
|
557
734
|
root = repo_root.resolve()
|
|
558
735
|
command = _system_editor_command(root)
|
|
@@ -584,6 +761,24 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
|
|
|
584
761
|
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
585
762
|
|
|
586
763
|
|
|
764
|
+
def _run_cdx_mission(repo_root: Path, args: list[str], *, timeout: int, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
765
|
+
command = ["cdx", *args]
|
|
766
|
+
cdx_runner = runner or subprocess.run
|
|
767
|
+
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=timeout)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _run_logics_flow(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
771
|
+
command = ["logics-manager", "flow", *args]
|
|
772
|
+
flow_runner = runner or subprocess.run
|
|
773
|
+
return flow_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _run_logics_command(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
777
|
+
command = ["logics-manager", *args]
|
|
778
|
+
logics_runner = runner or subprocess.run
|
|
779
|
+
return logics_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=30)
|
|
780
|
+
|
|
781
|
+
|
|
587
782
|
def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
588
783
|
command = ["gh", *args]
|
|
589
784
|
gh_runner = runner or subprocess.run
|
|
@@ -711,7 +906,11 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
|
|
|
711
906
|
}
|
|
712
907
|
|
|
713
908
|
|
|
714
|
-
|
|
909
|
+
GIT_HISTORY_DISPLAY_LIMIT = 50
|
|
910
|
+
GIT_HISTORY_FETCH_LIMIT = GIT_HISTORY_DISPLAY_LIMIT + 1
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _parse_recent_git_commits(output: str, *, limit: int | None = None) -> list[dict[str, str]]:
|
|
715
914
|
commits: list[dict[str, str]] = []
|
|
716
915
|
for line in output.splitlines():
|
|
717
916
|
parts = line.split("\x1f")
|
|
@@ -727,6 +926,8 @@ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
|
|
|
727
926
|
"refs": _sanitize_git_ref(refs),
|
|
728
927
|
}
|
|
729
928
|
)
|
|
929
|
+
if limit is not None and len(commits) >= limit:
|
|
930
|
+
break
|
|
730
931
|
return commits
|
|
731
932
|
|
|
732
933
|
|
|
@@ -813,7 +1014,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
813
1014
|
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
814
1015
|
recent_commits = _run_read_only_git(
|
|
815
1016
|
repo_root,
|
|
816
|
-
["log", "-
|
|
1017
|
+
["log", f"-{GIT_HISTORY_FETCH_LIMIT}", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
817
1018
|
runner=runner,
|
|
818
1019
|
)
|
|
819
1020
|
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
@@ -840,6 +1041,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
840
1041
|
counts = {key: len(value) for key, value in groups.items()}
|
|
841
1042
|
uncommitted_files = _count_unique_git_status_paths(groups)
|
|
842
1043
|
dirty = any(counts.values())
|
|
1044
|
+
parsed_recent_commits = _parse_recent_git_commits(recent_commits.stdout, limit=GIT_HISTORY_FETCH_LIMIT) if recent_commits.returncode == 0 else []
|
|
843
1045
|
return {
|
|
844
1046
|
"state": "ok",
|
|
845
1047
|
**branch_info,
|
|
@@ -860,10 +1062,18 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
860
1062
|
},
|
|
861
1063
|
"groups": groups,
|
|
862
1064
|
"latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
|
|
863
|
-
"recentCommits":
|
|
1065
|
+
"recentCommits": parsed_recent_commits[:GIT_HISTORY_DISPLAY_LIMIT],
|
|
1066
|
+
"recentCommitsHasMore": len(parsed_recent_commits) > GIT_HISTORY_DISPLAY_LIMIT,
|
|
864
1067
|
}
|
|
865
1068
|
|
|
866
1069
|
|
|
1070
|
+
def _normalize_git_file_path(rel_path: str) -> str | None:
|
|
1071
|
+
normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
|
|
1072
|
+
if not normalized or normalized.startswith("~") or normalized.startswith("/") or ".." in normalized.split("/"):
|
|
1073
|
+
return None
|
|
1074
|
+
return normalized
|
|
1075
|
+
|
|
1076
|
+
|
|
867
1077
|
def git_diff_payload(
|
|
868
1078
|
repo_root: Path,
|
|
869
1079
|
rel_path: str,
|
|
@@ -876,8 +1086,8 @@ def git_diff_payload(
|
|
|
876
1086
|
git_which = which or shutil.which
|
|
877
1087
|
if not git_which("git"):
|
|
878
1088
|
return {"state": "unavailable", "message": "Git is not available on PATH."}
|
|
879
|
-
normalized =
|
|
880
|
-
if not normalized
|
|
1089
|
+
normalized = _normalize_git_file_path(rel_path)
|
|
1090
|
+
if not normalized:
|
|
881
1091
|
return {"state": "error", "message": "Unsafe Git path."}
|
|
882
1092
|
try:
|
|
883
1093
|
inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
@@ -912,6 +1122,233 @@ def git_diff_payload(
|
|
|
912
1122
|
}
|
|
913
1123
|
|
|
914
1124
|
|
|
1125
|
+
def git_file_preview_payload(
|
|
1126
|
+
repo_root: Path,
|
|
1127
|
+
rel_path: str,
|
|
1128
|
+
*,
|
|
1129
|
+
max_bytes: int = GIT_FILE_PREVIEW_MAX_BYTES,
|
|
1130
|
+
max_chars: int = GIT_FILE_PREVIEW_MAX_CHARS,
|
|
1131
|
+
) -> dict[str, Any]:
|
|
1132
|
+
normalized = _normalize_git_file_path(rel_path)
|
|
1133
|
+
if not normalized:
|
|
1134
|
+
return {"state": "error", "message": "Unsafe Git path."}
|
|
1135
|
+
target = (repo_root / normalized).resolve()
|
|
1136
|
+
try:
|
|
1137
|
+
target.relative_to(repo_root.resolve())
|
|
1138
|
+
except ValueError:
|
|
1139
|
+
return {"state": "error", "message": "Unsafe Git path."}
|
|
1140
|
+
if not target.exists() or not target.is_file():
|
|
1141
|
+
return {
|
|
1142
|
+
"state": "missing",
|
|
1143
|
+
"path": normalized,
|
|
1144
|
+
"message": "The current file is missing or deleted, so no file preview is available.",
|
|
1145
|
+
}
|
|
1146
|
+
try:
|
|
1147
|
+
size = target.stat().st_size
|
|
1148
|
+
except OSError as exc:
|
|
1149
|
+
return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
|
|
1150
|
+
if size > max_bytes:
|
|
1151
|
+
return {
|
|
1152
|
+
"state": "oversized",
|
|
1153
|
+
"path": normalized,
|
|
1154
|
+
"size": size,
|
|
1155
|
+
"message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
|
|
1156
|
+
}
|
|
1157
|
+
try:
|
|
1158
|
+
data = target.read_bytes()
|
|
1159
|
+
except OSError as exc:
|
|
1160
|
+
return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
|
|
1161
|
+
if b"\x00" in data:
|
|
1162
|
+
return {
|
|
1163
|
+
"state": "unsupported",
|
|
1164
|
+
"path": normalized,
|
|
1165
|
+
"message": "Binary or unsupported file content cannot be previewed.",
|
|
1166
|
+
}
|
|
1167
|
+
try:
|
|
1168
|
+
content = data.decode("utf-8")
|
|
1169
|
+
except UnicodeDecodeError:
|
|
1170
|
+
return {
|
|
1171
|
+
"state": "unsupported",
|
|
1172
|
+
"path": normalized,
|
|
1173
|
+
"message": "Binary or unsupported file encoding cannot be previewed.",
|
|
1174
|
+
}
|
|
1175
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
1176
|
+
truncated = len(content) > max_chars
|
|
1177
|
+
if truncated:
|
|
1178
|
+
content = content[:max_chars]
|
|
1179
|
+
return {
|
|
1180
|
+
"state": "ok",
|
|
1181
|
+
"path": normalized,
|
|
1182
|
+
"mode": "file-preview",
|
|
1183
|
+
"content": content,
|
|
1184
|
+
"truncated": truncated,
|
|
1185
|
+
"logicsType": _logics_doc_type(normalized),
|
|
1186
|
+
"message": "",
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
|
|
1190
|
+
def _normalize_workspace_path(rel_path: str) -> str:
|
|
1191
|
+
normalized = unquote(rel_path or "").replace("\\", "/").strip()
|
|
1192
|
+
normalized = normalized.lstrip("/")
|
|
1193
|
+
if normalized in {"", "."}:
|
|
1194
|
+
return ""
|
|
1195
|
+
if normalized.startswith("~") or re.match(r"^[A-Za-z]:", normalized):
|
|
1196
|
+
raise ValueError("Unsafe workspace path.")
|
|
1197
|
+
parts = [part for part in normalized.split("/") if part not in {"", "."}]
|
|
1198
|
+
if any(part == ".." for part in parts):
|
|
1199
|
+
raise ValueError("Workspace path escapes root.")
|
|
1200
|
+
return "/".join(parts)
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def _resolve_workspace_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
|
|
1204
|
+
normalized = _normalize_workspace_path(rel_path)
|
|
1205
|
+
root = repo_root.resolve()
|
|
1206
|
+
target = (root / normalized).resolve()
|
|
1207
|
+
try:
|
|
1208
|
+
target.relative_to(root)
|
|
1209
|
+
except ValueError as exc:
|
|
1210
|
+
raise ValueError("Workspace path escapes root.") from exc
|
|
1211
|
+
return normalized, target
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _workspace_entry_payload(root: Path, path: Path) -> dict[str, Any]:
|
|
1215
|
+
try:
|
|
1216
|
+
stat = path.stat()
|
|
1217
|
+
except OSError:
|
|
1218
|
+
stat = None
|
|
1219
|
+
rel_path = path.relative_to(root).as_posix()
|
|
1220
|
+
is_dir = path.is_dir()
|
|
1221
|
+
ignored = is_dir and path.name in WORKSPACE_IGNORED_DIRS
|
|
1222
|
+
return {
|
|
1223
|
+
"name": path.name or root.name,
|
|
1224
|
+
"path": rel_path,
|
|
1225
|
+
"kind": "directory" if is_dir else "file",
|
|
1226
|
+
"size": stat.st_size if stat else 0,
|
|
1227
|
+
"ignored": ignored,
|
|
1228
|
+
"childrenAvailable": is_dir and not ignored,
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def workspace_tree_payload(
|
|
1233
|
+
repo_root: Path,
|
|
1234
|
+
rel_path: str = "",
|
|
1235
|
+
*,
|
|
1236
|
+
max_entries: int = WORKSPACE_TREE_MAX_ENTRIES,
|
|
1237
|
+
) -> dict[str, Any]:
|
|
1238
|
+
normalized, target = _resolve_workspace_path(repo_root, rel_path)
|
|
1239
|
+
root = repo_root.resolve()
|
|
1240
|
+
if not target.exists():
|
|
1241
|
+
return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
|
|
1242
|
+
if not target.is_dir():
|
|
1243
|
+
return {"state": "not-directory", "path": normalized, "message": "Workspace path is not a directory."}
|
|
1244
|
+
entries = []
|
|
1245
|
+
truncated = False
|
|
1246
|
+
try:
|
|
1247
|
+
children = sorted(target.iterdir(), key=lambda path: (not path.is_dir(), path.name.lower()))
|
|
1248
|
+
except OSError as exc:
|
|
1249
|
+
return {"state": "error", "path": normalized, "message": f"Unable to list workspace path: {exc}"}
|
|
1250
|
+
for child in children:
|
|
1251
|
+
if len(entries) >= max_entries:
|
|
1252
|
+
truncated = True
|
|
1253
|
+
break
|
|
1254
|
+
entries.append(_workspace_entry_payload(root, child))
|
|
1255
|
+
return {
|
|
1256
|
+
"state": "ok",
|
|
1257
|
+
"root": str(root),
|
|
1258
|
+
"path": normalized,
|
|
1259
|
+
"entries": entries,
|
|
1260
|
+
"truncated": truncated,
|
|
1261
|
+
"ignoredDirectories": sorted(WORKSPACE_IGNORED_DIRS),
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def workspace_preview_payload(
|
|
1266
|
+
repo_root: Path,
|
|
1267
|
+
rel_path: str,
|
|
1268
|
+
*,
|
|
1269
|
+
max_bytes: int = WORKSPACE_PREVIEW_MAX_BYTES,
|
|
1270
|
+
max_chars: int = WORKSPACE_PREVIEW_MAX_CHARS,
|
|
1271
|
+
) -> dict[str, Any]:
|
|
1272
|
+
normalized, target = _resolve_workspace_path(repo_root, rel_path)
|
|
1273
|
+
if not target.exists():
|
|
1274
|
+
return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
|
|
1275
|
+
if target.is_dir():
|
|
1276
|
+
try:
|
|
1277
|
+
count = sum(1 for _ in target.iterdir())
|
|
1278
|
+
except OSError:
|
|
1279
|
+
count = 0
|
|
1280
|
+
return {
|
|
1281
|
+
"state": "directory",
|
|
1282
|
+
"path": normalized,
|
|
1283
|
+
"name": target.name or repo_root.resolve().name,
|
|
1284
|
+
"kind": "directory",
|
|
1285
|
+
"message": f"{count} item(s)",
|
|
1286
|
+
"childrenAvailable": target.name not in WORKSPACE_IGNORED_DIRS,
|
|
1287
|
+
}
|
|
1288
|
+
if not target.is_file():
|
|
1289
|
+
return {"state": "unsupported", "path": normalized, "message": "Workspace object cannot be previewed."}
|
|
1290
|
+
try:
|
|
1291
|
+
size = target.stat().st_size
|
|
1292
|
+
except OSError as exc:
|
|
1293
|
+
return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
|
|
1294
|
+
if size > max_bytes:
|
|
1295
|
+
return {
|
|
1296
|
+
"state": "oversized",
|
|
1297
|
+
"path": normalized,
|
|
1298
|
+
"name": target.name,
|
|
1299
|
+
"size": size,
|
|
1300
|
+
"message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
|
|
1301
|
+
}
|
|
1302
|
+
try:
|
|
1303
|
+
data = target.read_bytes()
|
|
1304
|
+
except OSError as exc:
|
|
1305
|
+
return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
|
|
1306
|
+
content_type = mimetypes.guess_type(target.name)[0] or ""
|
|
1307
|
+
if content_type.startswith("image/"):
|
|
1308
|
+
return {
|
|
1309
|
+
"state": "image",
|
|
1310
|
+
"path": normalized,
|
|
1311
|
+
"name": target.name,
|
|
1312
|
+
"size": size,
|
|
1313
|
+
"contentType": content_type,
|
|
1314
|
+
"message": "Image preview is available from the workspace file endpoint.",
|
|
1315
|
+
}
|
|
1316
|
+
if b"\x00" in data:
|
|
1317
|
+
return {
|
|
1318
|
+
"state": "unsupported",
|
|
1319
|
+
"path": normalized,
|
|
1320
|
+
"name": target.name,
|
|
1321
|
+
"size": size,
|
|
1322
|
+
"message": "Binary or unsupported file content cannot be previewed.",
|
|
1323
|
+
}
|
|
1324
|
+
try:
|
|
1325
|
+
content = data.decode("utf-8")
|
|
1326
|
+
except UnicodeDecodeError:
|
|
1327
|
+
return {
|
|
1328
|
+
"state": "unsupported",
|
|
1329
|
+
"path": normalized,
|
|
1330
|
+
"name": target.name,
|
|
1331
|
+
"size": size,
|
|
1332
|
+
"message": "Binary or unsupported file encoding cannot be previewed.",
|
|
1333
|
+
}
|
|
1334
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
1335
|
+
truncated = len(content) > max_chars
|
|
1336
|
+
if truncated:
|
|
1337
|
+
content = content[:max_chars]
|
|
1338
|
+
return {
|
|
1339
|
+
"state": "ok",
|
|
1340
|
+
"path": normalized,
|
|
1341
|
+
"name": target.name,
|
|
1342
|
+
"kind": "file",
|
|
1343
|
+
"size": size,
|
|
1344
|
+
"contentType": content_type or "text/plain",
|
|
1345
|
+
"content": content,
|
|
1346
|
+
"truncated": truncated,
|
|
1347
|
+
"logicsType": _logics_doc_type(normalized),
|
|
1348
|
+
"message": "",
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
|
|
915
1352
|
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
916
1353
|
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
917
1354
|
commands = {
|
|
@@ -946,11 +1383,34 @@ def _ci_badge_state(status: str, conclusion: str) -> str:
|
|
|
946
1383
|
return "unknown"
|
|
947
1384
|
|
|
948
1385
|
|
|
1386
|
+
def _is_active_ci_status(run: dict[str, Any]) -> bool:
|
|
1387
|
+
return str(run.get("status") or "").strip().lower() in {"queued", "in_progress", "waiting", "requested", "pending"}
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
|
|
1391
|
+
ci_runs = [run for run in runs if str(run.get("name") or "").strip().lower() == "ci"]
|
|
1392
|
+
candidate_runs = ci_runs or runs
|
|
1393
|
+
head_runs = [run for run in candidate_runs if head_sha and str(run.get("head_sha") or "") == head_sha]
|
|
1394
|
+
active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
|
|
1395
|
+
if active_head_run is not None:
|
|
1396
|
+
return active_head_run, "head-active"
|
|
1397
|
+
if head_runs:
|
|
1398
|
+
head_state = _ci_badge_state(str(head_runs[0].get("status") or ""), str(head_runs[0].get("conclusion") or ""))
|
|
1399
|
+
if head_state in {"failing", "cancelled", "unknown"}:
|
|
1400
|
+
return head_runs[0], f"head-{head_state}"
|
|
1401
|
+
return head_runs[0], "head"
|
|
1402
|
+
active_branch_run = next((run for run in candidate_runs if _is_active_ci_status(run)), None)
|
|
1403
|
+
if active_branch_run is not None:
|
|
1404
|
+
return active_branch_run, "branch-active"
|
|
1405
|
+
return candidate_runs[0], "branch-latest"
|
|
1406
|
+
|
|
1407
|
+
|
|
949
1408
|
def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
|
|
950
1409
|
status = str(run.get("status") or "")
|
|
951
1410
|
conclusion = str(run.get("conclusion") or "")
|
|
952
1411
|
commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
|
|
953
1412
|
author = commit.get("author") if isinstance(commit.get("author"), dict) else {}
|
|
1413
|
+
commit_lines = str(commit.get("message") or run.get("display_title") or "").splitlines()
|
|
954
1414
|
return {
|
|
955
1415
|
"id": run.get("id"),
|
|
956
1416
|
"name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
|
|
@@ -965,7 +1425,7 @@ def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict
|
|
|
965
1425
|
"createdAt": str(run.get("created_at") or ""),
|
|
966
1426
|
"updatedAt": str(run.get("updated_at") or ""),
|
|
967
1427
|
"runStartedAt": str(run.get("run_started_at") or ""),
|
|
968
|
-
"commitMessage":
|
|
1428
|
+
"commitMessage": commit_lines[0][:240] if commit_lines else "",
|
|
969
1429
|
"author": str(author.get("name") or ""),
|
|
970
1430
|
"matchSource": match_source,
|
|
971
1431
|
}
|
|
@@ -1021,7 +1481,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
|
|
|
1021
1481
|
context = _current_git_ci_context(repo_root, runner=git_runner)
|
|
1022
1482
|
branch = context.get("branch", "")
|
|
1023
1483
|
head_sha = context.get("headSha", "")
|
|
1024
|
-
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=
|
|
1484
|
+
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=30"
|
|
1025
1485
|
if branch:
|
|
1026
1486
|
endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
|
|
1027
1487
|
try:
|
|
@@ -1043,10 +1503,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
|
|
|
1043
1503
|
if not runs:
|
|
1044
1504
|
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
1505
|
|
|
1046
|
-
selected =
|
|
1047
|
-
match_source = "head" if selected else "branch-latest"
|
|
1048
|
-
if selected is None:
|
|
1049
|
-
selected = runs[0]
|
|
1506
|
+
selected, match_source = _select_github_actions_run(runs, head_sha)
|
|
1050
1507
|
run_payload = _parse_github_actions_run(selected, match_source=match_source)
|
|
1051
1508
|
jobs: list[dict[str, str]] = []
|
|
1052
1509
|
run_id = run_payload.get("id")
|
|
@@ -1116,7 +1573,18 @@ def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any |
|
|
|
1116
1573
|
runs = parsed.get("runs") if isinstance(parsed, dict) else None
|
|
1117
1574
|
if not isinstance(runs, list):
|
|
1118
1575
|
return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
|
|
1119
|
-
|
|
1576
|
+
normalized_runs: list[dict[str, Any]] = []
|
|
1577
|
+
for run in runs:
|
|
1578
|
+
if not isinstance(run, dict):
|
|
1579
|
+
continue
|
|
1580
|
+
item = dict(run)
|
|
1581
|
+
status = str(item.get("status") or item.get("state") or "").strip().lower()
|
|
1582
|
+
if status == "stale" and not item.get("ended_at") and not item.get("endedAt"):
|
|
1583
|
+
item["status"] = "running"
|
|
1584
|
+
item["status_detail"] = "CDX still marks this run active; no end timestamp has been reported yet."
|
|
1585
|
+
item["raw_status"] = "stale"
|
|
1586
|
+
normalized_runs.append(item)
|
|
1587
|
+
return {"state": "ok", "message": "", "runs": normalized_runs}
|
|
1120
1588
|
|
|
1121
1589
|
|
|
1122
1590
|
def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
@@ -1141,9 +1609,569 @@ def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None =
|
|
|
1141
1609
|
report = parsed.get("report") if isinstance(parsed, dict) else None
|
|
1142
1610
|
if not isinstance(report, dict):
|
|
1143
1611
|
return {"state": "invalid-json", "message": "CDX run-report JSON must include a report object.", "report": None}
|
|
1612
|
+
merged_report = _merge_cdx_mission_output(report)
|
|
1613
|
+
if merged_report:
|
|
1614
|
+
report = merged_report
|
|
1144
1615
|
return {"state": "ok", "message": "", "report": report}
|
|
1145
1616
|
|
|
1146
1617
|
|
|
1618
|
+
def cdx_mission_catalog_payload() -> dict[str, Any]:
|
|
1619
|
+
return {
|
|
1620
|
+
"missions": list(CDX_MISSION_CATALOG.values()),
|
|
1621
|
+
"strengths": list(CDX_MISSION_STRENGTHS.values()),
|
|
1622
|
+
"defaultMissionId": CDX_DEFAULT_MISSION_ID,
|
|
1623
|
+
"defaultStrengthId": "standard",
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
def _cdx_status_sessions(status_payload: dict[str, Any]) -> list[str]:
|
|
1628
|
+
status = status_payload.get("status") if isinstance(status_payload.get("status"), dict) else {}
|
|
1629
|
+
sessions = status.get("sessions") if isinstance(status.get("sessions"), list) else []
|
|
1630
|
+
ids: list[str] = []
|
|
1631
|
+
for session in sessions:
|
|
1632
|
+
if not isinstance(session, dict):
|
|
1633
|
+
continue
|
|
1634
|
+
session_id = str(session.get("id") or session.get("name") or "").strip()
|
|
1635
|
+
if session_id:
|
|
1636
|
+
ids.append(session_id)
|
|
1637
|
+
return ids
|
|
1638
|
+
|
|
1639
|
+
|
|
1640
|
+
def _normalize_cdx_session(value: Any, status_payload: dict[str, Any] | None = None) -> str:
|
|
1641
|
+
session = str(value or "").strip()
|
|
1642
|
+
if not re.match(r"^[A-Za-z0-9_.:@/-]{1,120}$", session):
|
|
1643
|
+
return ""
|
|
1644
|
+
if status_payload is None:
|
|
1645
|
+
return session
|
|
1646
|
+
known_sessions = _cdx_status_sessions(status_payload)
|
|
1647
|
+
if known_sessions and session not in known_sessions:
|
|
1648
|
+
return ""
|
|
1649
|
+
return session
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
def _latest_release_tag(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> str:
|
|
1653
|
+
git_which = which or shutil.which
|
|
1654
|
+
if not git_which("git"):
|
|
1655
|
+
return ""
|
|
1656
|
+
commands = [
|
|
1657
|
+
["tag", "--sort=-version:refname", "--list", "v[0-9]*"],
|
|
1658
|
+
["tag", "--sort=-version:refname", "--list", "[0-9]*"],
|
|
1659
|
+
["describe", "--tags", "--abbrev=0"],
|
|
1660
|
+
]
|
|
1661
|
+
for args in commands:
|
|
1662
|
+
try:
|
|
1663
|
+
result = _run_read_only_git(repo_root, args, runner=runner)
|
|
1664
|
+
except (OSError, subprocess.SubprocessError, subprocess.TimeoutExpired):
|
|
1665
|
+
continue
|
|
1666
|
+
if result.returncode != 0:
|
|
1667
|
+
continue
|
|
1668
|
+
tag = (result.stdout or "").strip().splitlines()[0] if (result.stdout or "").strip() else ""
|
|
1669
|
+
if tag:
|
|
1670
|
+
return tag[:200]
|
|
1671
|
+
return ""
|
|
1672
|
+
|
|
1673
|
+
|
|
1674
|
+
def _mission_text_input(body: dict[str, Any], key: str, *, max_chars: int = 4000) -> str:
|
|
1675
|
+
raw = str(body.get(key) or "").strip()
|
|
1676
|
+
normalized = re.sub(r"\s+", " ", raw)
|
|
1677
|
+
return normalized[:max_chars]
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def _mission_bool_input(body: dict[str, Any], key: str) -> bool:
|
|
1681
|
+
value = body.get(key)
|
|
1682
|
+
if isinstance(value, bool):
|
|
1683
|
+
return value
|
|
1684
|
+
if isinstance(value, str):
|
|
1685
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
1686
|
+
return False
|
|
1687
|
+
|
|
1688
|
+
|
|
1689
|
+
def _cdx_mission_prompt(
|
|
1690
|
+
mission_id: str,
|
|
1691
|
+
*,
|
|
1692
|
+
release_tag: str = "",
|
|
1693
|
+
wish_text: str = "",
|
|
1694
|
+
release_version: str = "",
|
|
1695
|
+
run_full_validation: bool = False,
|
|
1696
|
+
allow_file_writes: bool = False,
|
|
1697
|
+
direct_fixes: bool = False,
|
|
1698
|
+
commit_at_end: bool = False,
|
|
1699
|
+
) -> str:
|
|
1700
|
+
write_guidance = (
|
|
1701
|
+
"File edits are allowed when they directly complete the selected mission mode. Keep changes scoped, run relevant validation, and report changed files."
|
|
1702
|
+
if allow_file_writes
|
|
1703
|
+
else "Do not modify files."
|
|
1704
|
+
)
|
|
1705
|
+
commit_guidance = (
|
|
1706
|
+
"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."
|
|
1707
|
+
if commit_at_end
|
|
1708
|
+
else "Do not create git commits."
|
|
1709
|
+
)
|
|
1710
|
+
if mission_id == "full-audit":
|
|
1711
|
+
if direct_fixes:
|
|
1712
|
+
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."
|
|
1713
|
+
schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
|
|
1714
|
+
elif allow_file_writes:
|
|
1715
|
+
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."
|
|
1716
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
|
|
1717
|
+
else:
|
|
1718
|
+
action_guidance = "Report only; do not write corpus files, fix issues, or modify files."
|
|
1719
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations."
|
|
1720
|
+
return "\n".join([
|
|
1721
|
+
"Run a full repository audit for this Logics Manager checkout.",
|
|
1722
|
+
"Focus on correctness bugs, workflow risks, missing validation, stale documentation, and test gaps.",
|
|
1723
|
+
write_guidance,
|
|
1724
|
+
action_guidance,
|
|
1725
|
+
commit_guidance,
|
|
1726
|
+
schema,
|
|
1727
|
+
])
|
|
1728
|
+
if mission_id == "release-review":
|
|
1729
|
+
if direct_fixes:
|
|
1730
|
+
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."
|
|
1731
|
+
schema = "Return concise JSON with keys: summary, findings, directFixes, changedFiles, validationEvidence."
|
|
1732
|
+
elif allow_file_writes:
|
|
1733
|
+
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."
|
|
1734
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations, requestFiles, validationEvidence."
|
|
1735
|
+
else:
|
|
1736
|
+
action_guidance = "Report only; do not update release files, write corpus files, fix issues, tag, push, publish, upload assets, or create GitHub releases."
|
|
1737
|
+
schema = "Return concise JSON with keys: summary, findings, recommendations."
|
|
1738
|
+
return "\n".join([
|
|
1739
|
+
f"Review repository changes since the latest release tag {release_tag}.",
|
|
1740
|
+
"Focus on regressions, incomplete release notes, migration risks, and missing tests.",
|
|
1741
|
+
write_guidance,
|
|
1742
|
+
action_guidance,
|
|
1743
|
+
commit_guidance,
|
|
1744
|
+
schema,
|
|
1745
|
+
])
|
|
1746
|
+
if mission_id == "corpus-ready":
|
|
1747
|
+
return "\n".join([
|
|
1748
|
+
"Prepare the open Logics workflow corpus for development.",
|
|
1749
|
+
"Analyze requests, backlog items, tasks, docs, lint/audit state, and workflow consistency.",
|
|
1750
|
+
"Do not modify files directly. This mission is plan-first: return allowed actions for the viewer to apply explicitly.",
|
|
1751
|
+
"Do not run destructive commands.",
|
|
1752
|
+
"Return JSON only with this schema:",
|
|
1753
|
+
'{"summary":"...","actions":[{"type":"promote-request-to-backlog","target":"req_..."},{"type":"promote-backlog-to-task","target":"item_..."},{"type":"refresh-corpus-context","target":""}],"notes":["..."]}',
|
|
1754
|
+
"Allowed action types are exactly: promote-request-to-backlog, promote-backlog-to-task, refresh-corpus-context.",
|
|
1755
|
+
"Use only targets that exist in the repository. Omit actions that are not clearly justified.",
|
|
1756
|
+
])
|
|
1757
|
+
if mission_id == "wish-to-request":
|
|
1758
|
+
request_guidance = (
|
|
1759
|
+
"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."
|
|
1760
|
+
if allow_file_writes
|
|
1761
|
+
else "Do not create the request file; return the request draft and generatedFiles preview only."
|
|
1762
|
+
)
|
|
1763
|
+
return "\n".join([
|
|
1764
|
+
"Turn the following user wish into a structured Logics request draft.",
|
|
1765
|
+
write_guidance,
|
|
1766
|
+
request_guidance,
|
|
1767
|
+
commit_guidance,
|
|
1768
|
+
"Do not promote backlog items and do not create tasks.",
|
|
1769
|
+
"Return JSON only with this schema:",
|
|
1770
|
+
'{"summary":"...","requestDraft":{"title":"...","needs":["..."],"context":["..."],"acceptanceCriteria":["AC1: ..."],"definitionOfReady":{"problemExplicit":true,"scopeBounded":true,"criteriaTestable":true,"risksListed":true},"references":["..."],"questions":["..."],"openAssumptions":["..."]},"generatedFiles":[]}',
|
|
1771
|
+
"If the wish is underspecified, include concrete questions and open assumptions instead of inventing details.",
|
|
1772
|
+
"User wish:",
|
|
1773
|
+
wish_text,
|
|
1774
|
+
])
|
|
1775
|
+
if mission_id == "pre-release":
|
|
1776
|
+
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."
|
|
1777
|
+
release_prep_guidance = (
|
|
1778
|
+
"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."
|
|
1779
|
+
if allow_file_writes
|
|
1780
|
+
else "Do not modify package versions, changelog files, create Git tags, push branches, publish packages, upload release assets, or create GitHub releases."
|
|
1781
|
+
)
|
|
1782
|
+
return "\n".join([
|
|
1783
|
+
f"Prepare a guarded pre-release for version {release_version}.",
|
|
1784
|
+
validation_mode,
|
|
1785
|
+
release_prep_guidance,
|
|
1786
|
+
write_guidance,
|
|
1787
|
+
commit_guidance,
|
|
1788
|
+
"Return JSON only with this schema:",
|
|
1789
|
+
'{"summary":"...","version":"vX.X.X","validationMode":"full|plan-only","validationEvidence":["..."],"actionableFixes":[{"title":"...","command":"...","risk":"..."}],"generatedFiles":[{"path":"...","purpose":"..."}],"releasePlan":["..."],"blocked":false}',
|
|
1790
|
+
])
|
|
1791
|
+
raise ValueError("Unknown CDX mission.")
|
|
1792
|
+
|
|
1793
|
+
|
|
1794
|
+
def _cdx_mission_timeout(strength: dict[str, Any], *, allow_file_writes: bool = False, commit_at_end: bool = False) -> int:
|
|
1795
|
+
timeout = int(strength.get("timeout") or 180)
|
|
1796
|
+
if allow_file_writes or commit_at_end:
|
|
1797
|
+
return max(timeout, CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS)
|
|
1798
|
+
return timeout
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def _cdx_mission_command(
|
|
1802
|
+
repo_root: Path,
|
|
1803
|
+
mission_id: str,
|
|
1804
|
+
*,
|
|
1805
|
+
session: str,
|
|
1806
|
+
strength: dict[str, Any],
|
|
1807
|
+
release_tag: str = "",
|
|
1808
|
+
mission_inputs: dict[str, str] | None = None,
|
|
1809
|
+
allow_file_writes: bool = False,
|
|
1810
|
+
commit_at_end: bool = False,
|
|
1811
|
+
) -> list[str]:
|
|
1812
|
+
mission_inputs = mission_inputs or {}
|
|
1813
|
+
prompt = _cdx_mission_prompt(
|
|
1814
|
+
mission_id,
|
|
1815
|
+
release_tag=release_tag,
|
|
1816
|
+
wish_text=mission_inputs.get("wishText", ""),
|
|
1817
|
+
release_version=mission_inputs.get("releaseVersion", ""),
|
|
1818
|
+
run_full_validation=mission_inputs.get("runFullValidation") == "true",
|
|
1819
|
+
allow_file_writes=allow_file_writes,
|
|
1820
|
+
direct_fixes=mission_inputs.get("directFixes") == "true",
|
|
1821
|
+
commit_at_end=commit_at_end,
|
|
1822
|
+
)
|
|
1823
|
+
timeout = _cdx_mission_timeout(strength, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end)
|
|
1824
|
+
reasoning_effort = str(strength.get("reasoningEffort") or "medium")
|
|
1825
|
+
power = str(strength.get("power") or "medium")
|
|
1826
|
+
permission = "workspace-write" if allow_file_writes else "read-only"
|
|
1827
|
+
return [
|
|
1828
|
+
"run",
|
|
1829
|
+
session,
|
|
1830
|
+
"--cwd",
|
|
1831
|
+
str(repo_root),
|
|
1832
|
+
"--prompt",
|
|
1833
|
+
prompt,
|
|
1834
|
+
"--kind",
|
|
1835
|
+
"assistant",
|
|
1836
|
+
"--reasoning-effort",
|
|
1837
|
+
reasoning_effort,
|
|
1838
|
+
"--power",
|
|
1839
|
+
power,
|
|
1840
|
+
"--permission",
|
|
1841
|
+
permission,
|
|
1842
|
+
"--timeout-seconds",
|
|
1843
|
+
str(timeout),
|
|
1844
|
+
"--json",
|
|
1845
|
+
]
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _parse_json_from_text(text: str) -> dict[str, Any] | None:
|
|
1849
|
+
raw = text.strip()
|
|
1850
|
+
if not raw:
|
|
1851
|
+
return None
|
|
1852
|
+
jsonl_candidates: list[str] = []
|
|
1853
|
+
for line in reversed(raw.splitlines()):
|
|
1854
|
+
line = line.strip()
|
|
1855
|
+
if not line.startswith("{"):
|
|
1856
|
+
continue
|
|
1857
|
+
try:
|
|
1858
|
+
event = json.loads(line)
|
|
1859
|
+
except json.JSONDecodeError:
|
|
1860
|
+
continue
|
|
1861
|
+
if not isinstance(event, dict):
|
|
1862
|
+
continue
|
|
1863
|
+
item = event.get("item") if isinstance(event.get("item"), dict) else {}
|
|
1864
|
+
text_value = item.get("text") if item.get("type") == "agent_message" else event.get("text")
|
|
1865
|
+
if isinstance(text_value, str) and text_value.strip():
|
|
1866
|
+
jsonl_candidates.append(text_value.strip())
|
|
1867
|
+
candidates = [raw]
|
|
1868
|
+
candidates.extend(jsonl_candidates)
|
|
1869
|
+
fence_match = re.search(r"```(?:json)?\s*(.*?)```", raw, re.IGNORECASE | re.DOTALL)
|
|
1870
|
+
if fence_match:
|
|
1871
|
+
candidates.insert(0, fence_match.group(1).strip())
|
|
1872
|
+
decoder = json.JSONDecoder()
|
|
1873
|
+
fallback: dict[str, Any] | None = None
|
|
1874
|
+
for candidate in candidates:
|
|
1875
|
+
try:
|
|
1876
|
+
parsed = json.loads(candidate)
|
|
1877
|
+
if isinstance(parsed, dict):
|
|
1878
|
+
if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
|
|
1879
|
+
return parsed
|
|
1880
|
+
fallback = fallback or parsed
|
|
1881
|
+
except json.JSONDecodeError:
|
|
1882
|
+
pass
|
|
1883
|
+
for index, char in enumerate(candidate):
|
|
1884
|
+
if char != "{":
|
|
1885
|
+
continue
|
|
1886
|
+
try:
|
|
1887
|
+
parsed, _end = decoder.raw_decode(candidate[index:])
|
|
1888
|
+
except json.JSONDecodeError:
|
|
1889
|
+
continue
|
|
1890
|
+
if isinstance(parsed, dict):
|
|
1891
|
+
if any(key in parsed for key in ("actions", "summary", "findings", "recommendations")):
|
|
1892
|
+
return parsed
|
|
1893
|
+
fallback = fallback or parsed
|
|
1894
|
+
return fallback
|
|
1895
|
+
|
|
1896
|
+
|
|
1897
|
+
def _read_cdx_output_path(parsed: dict[str, Any]) -> str:
|
|
1898
|
+
candidates = [
|
|
1899
|
+
parsed.get("stdout"),
|
|
1900
|
+
parsed.get("output"),
|
|
1901
|
+
]
|
|
1902
|
+
artifacts = parsed.get("artifacts") if isinstance(parsed.get("artifacts"), dict) else {}
|
|
1903
|
+
candidates.extend([
|
|
1904
|
+
parsed.get("stdout_path"),
|
|
1905
|
+
parsed.get("stdoutPath"),
|
|
1906
|
+
artifacts.get("stdout_path"),
|
|
1907
|
+
artifacts.get("stdoutPath"),
|
|
1908
|
+
])
|
|
1909
|
+
for candidate in candidates:
|
|
1910
|
+
if not isinstance(candidate, str) or not candidate.strip():
|
|
1911
|
+
continue
|
|
1912
|
+
value = candidate.strip()
|
|
1913
|
+
if "\n" in value or value.lstrip().startswith("{") or value.lstrip().startswith("```"):
|
|
1914
|
+
return value[:12000]
|
|
1915
|
+
path = Path(value).expanduser()
|
|
1916
|
+
if not path.is_file():
|
|
1917
|
+
continue
|
|
1918
|
+
try:
|
|
1919
|
+
with path.open("rb") as handle:
|
|
1920
|
+
size = path.stat().st_size
|
|
1921
|
+
if size > 60000:
|
|
1922
|
+
handle.seek(size - 60000)
|
|
1923
|
+
return handle.read(60000).decode("utf-8", errors="replace")
|
|
1924
|
+
except OSError:
|
|
1925
|
+
continue
|
|
1926
|
+
return ""
|
|
1927
|
+
|
|
1928
|
+
|
|
1929
|
+
def _merge_cdx_mission_output(parsed: Any) -> dict[str, Any] | None:
|
|
1930
|
+
if not isinstance(parsed, dict):
|
|
1931
|
+
return None
|
|
1932
|
+
merged = dict(parsed)
|
|
1933
|
+
embedded = _parse_json_from_text(_read_cdx_output_path(parsed))
|
|
1934
|
+
if embedded:
|
|
1935
|
+
merged["missionOutput"] = embedded
|
|
1936
|
+
if isinstance(embedded.get("actions"), list) and "actions" not in merged:
|
|
1937
|
+
merged["actions"] = embedded["actions"]
|
|
1938
|
+
if "summary" in embedded and "summary" not in merged:
|
|
1939
|
+
merged["summary"] = embedded["summary"]
|
|
1940
|
+
return merged
|
|
1941
|
+
|
|
1942
|
+
|
|
1943
|
+
def _extract_cdx_usage(parsed: Any) -> dict[str, Any]:
|
|
1944
|
+
if not isinstance(parsed, dict):
|
|
1945
|
+
return {"available": False, "message": "CDX did not return structured usage."}
|
|
1946
|
+
candidates = [
|
|
1947
|
+
parsed.get("usage"),
|
|
1948
|
+
parsed.get("tokenUsage"),
|
|
1949
|
+
parsed.get("tokens"),
|
|
1950
|
+
(parsed.get("run") or {}).get("usage") if isinstance(parsed.get("run"), dict) else None,
|
|
1951
|
+
(parsed.get("result") or {}).get("usage") if isinstance(parsed.get("result"), dict) else None,
|
|
1952
|
+
]
|
|
1953
|
+
usage = next((candidate for candidate in candidates if isinstance(candidate, dict)), None)
|
|
1954
|
+
if usage is None:
|
|
1955
|
+
return {"available": False, "message": "Token usage was not exposed by CDX for this run."}
|
|
1956
|
+
input_tokens = usage.get("input_tokens", usage.get("inputTokens", usage.get("prompt_tokens", usage.get("promptTokens"))))
|
|
1957
|
+
output_tokens = usage.get("output_tokens", usage.get("outputTokens", usage.get("completion_tokens", usage.get("completionTokens"))))
|
|
1958
|
+
total_tokens = usage.get("total_tokens", usage.get("totalTokens"))
|
|
1959
|
+
if total_tokens is None and isinstance(input_tokens, int) and isinstance(output_tokens, int):
|
|
1960
|
+
total_tokens = input_tokens + output_tokens
|
|
1961
|
+
return {
|
|
1962
|
+
"available": True,
|
|
1963
|
+
"inputTokens": input_tokens,
|
|
1964
|
+
"outputTokens": output_tokens,
|
|
1965
|
+
"totalTokens": total_tokens,
|
|
1966
|
+
"raw": usage,
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
|
|
1970
|
+
def _bounded_process_text(value: str, limit: int = 12000) -> str:
|
|
1971
|
+
text = value.strip()
|
|
1972
|
+
if len(text) <= limit:
|
|
1973
|
+
return text
|
|
1974
|
+
return f"{text[:limit]}\n... truncated ..."
|
|
1975
|
+
|
|
1976
|
+
|
|
1977
|
+
def cdx_mission_plan_payload(
|
|
1978
|
+
repo_root: Path,
|
|
1979
|
+
body: dict[str, Any],
|
|
1980
|
+
*,
|
|
1981
|
+
cdx_runner: Any | None = None,
|
|
1982
|
+
git_runner: Any | None = None,
|
|
1983
|
+
which: Any | None = None,
|
|
1984
|
+
) -> dict[str, Any]:
|
|
1985
|
+
tool_which = which or shutil.which
|
|
1986
|
+
if not tool_which("cdx"):
|
|
1987
|
+
return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "plan": None}
|
|
1988
|
+
mission_id = str(body.get("missionId") or CDX_DEFAULT_MISSION_ID)
|
|
1989
|
+
mission = CDX_MISSION_CATALOG.get(mission_id)
|
|
1990
|
+
if mission is None:
|
|
1991
|
+
return {"state": "error", "message": "Unknown CDX mission.", "plan": None}
|
|
1992
|
+
strength = str(body.get("strengthId") or "standard")
|
|
1993
|
+
strength_def = CDX_MISSION_STRENGTHS.get(strength)
|
|
1994
|
+
if strength_def is None:
|
|
1995
|
+
return {"state": "error", "message": "Unknown CDX mission strength.", "plan": None}
|
|
1996
|
+
|
|
1997
|
+
status_payload = cdx_status_payload(repo_root, runner=cdx_runner, which=which)
|
|
1998
|
+
session = _normalize_cdx_session(body.get("sessionId"), status_payload if status_payload.get("state") == "ok" else None)
|
|
1999
|
+
if not session:
|
|
2000
|
+
sessions = _cdx_status_sessions(status_payload)
|
|
2001
|
+
session = sessions[0] if sessions else ""
|
|
2002
|
+
if not session:
|
|
2003
|
+
return {"state": "error", "message": "No usable CDX session is available.", "plan": None, "status": status_payload}
|
|
2004
|
+
|
|
2005
|
+
release_tag = ""
|
|
2006
|
+
warnings: list[str] = []
|
|
2007
|
+
mission_inputs: dict[str, str] = {}
|
|
2008
|
+
if mission_id == "wish-to-request":
|
|
2009
|
+
wish_text = _mission_text_input(body, "wishText")
|
|
2010
|
+
if not wish_text:
|
|
2011
|
+
return {"state": "error", "message": "Enter a wish or intent before previewing this mission.", "plan": None, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
|
|
2012
|
+
mission_inputs["wishText"] = wish_text
|
|
2013
|
+
if mission_id in {"full-audit", "release-review"}:
|
|
2014
|
+
mission_inputs["directFixes"] = "true" if _mission_bool_input(body, "directFixes") else "false"
|
|
2015
|
+
if mission_id == "pre-release":
|
|
2016
|
+
release_version = _mission_text_input(body, "releaseVersion", max_chars=40)
|
|
2017
|
+
if not re.fullmatch(r"v\d+\.\d+\.\d+", release_version):
|
|
2018
|
+
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}
|
|
2019
|
+
mission_inputs["releaseVersion"] = release_version
|
|
2020
|
+
mission_inputs["runFullValidation"] = "true" if _mission_bool_input(body, "runFullValidation") else "false"
|
|
2021
|
+
if mission.get("requiresReleaseTag"):
|
|
2022
|
+
release_tag = _latest_release_tag(repo_root, runner=git_runner, which=which)
|
|
2023
|
+
if not release_tag:
|
|
2024
|
+
return {"state": "error", "message": "No release tag was found for this mission.", "plan": None, "status": status_payload}
|
|
2025
|
+
if status_payload.get("state") != "ok":
|
|
2026
|
+
warnings.append(str(status_payload.get("message") or "CDX status could not be confirmed."))
|
|
2027
|
+
|
|
2028
|
+
requested_file_writes = _mission_bool_input(body, "allowFileWrites")
|
|
2029
|
+
requested_commit_at_end = _mission_bool_input(body, "commitAtEnd")
|
|
2030
|
+
direct_fixes = mission_inputs.get("directFixes") == "true"
|
|
2031
|
+
supports_file_writes = bool(mission.get("supportsFileWrites", True))
|
|
2032
|
+
allow_file_writes = (requested_file_writes or direct_fixes) and supports_file_writes
|
|
2033
|
+
commit_at_end = requested_commit_at_end and allow_file_writes
|
|
2034
|
+
if requested_file_writes and not supports_file_writes:
|
|
2035
|
+
warnings.append("This mission is plan-first; direct CDX file writes are disabled. Use Apply allowed actions after CDX returns actions.")
|
|
2036
|
+
if requested_commit_at_end and not allow_file_writes:
|
|
2037
|
+
warnings.append("Commit-at-end was requested but direct file writes are disabled for this mission.")
|
|
2038
|
+
permission = "workspace-write" if allow_file_writes else "read-only"
|
|
2039
|
+
command = _cdx_mission_command(
|
|
2040
|
+
repo_root,
|
|
2041
|
+
mission_id,
|
|
2042
|
+
session=session,
|
|
2043
|
+
strength=strength_def,
|
|
2044
|
+
release_tag=release_tag,
|
|
2045
|
+
mission_inputs=mission_inputs,
|
|
2046
|
+
allow_file_writes=allow_file_writes,
|
|
2047
|
+
commit_at_end=commit_at_end,
|
|
2048
|
+
)
|
|
2049
|
+
plan = {
|
|
2050
|
+
"mission": mission,
|
|
2051
|
+
"missionId": mission_id,
|
|
2052
|
+
"sessionId": session,
|
|
2053
|
+
"strength": strength_def,
|
|
2054
|
+
"strengthId": strength,
|
|
2055
|
+
"missionInputs": mission_inputs,
|
|
2056
|
+
"scope": mission["scope"],
|
|
2057
|
+
"releaseTag": release_tag,
|
|
2058
|
+
"allowFileWrites": allow_file_writes,
|
|
2059
|
+
"requestedFileWrites": requested_file_writes,
|
|
2060
|
+
"commitAtEnd": commit_at_end,
|
|
2061
|
+
"requestedCommitAtEnd": requested_commit_at_end,
|
|
2062
|
+
"supportsFileWrites": supports_file_writes,
|
|
2063
|
+
"permission": permission,
|
|
2064
|
+
"timeoutSeconds": _cdx_mission_timeout(strength_def, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end),
|
|
2065
|
+
"command": ["cdx", *command],
|
|
2066
|
+
"arguments": command,
|
|
2067
|
+
"warnings": warnings,
|
|
2068
|
+
"requiresConfirmation": bool(mission.get("requiresPlanConfirmation")),
|
|
2069
|
+
"canRun": True,
|
|
2070
|
+
}
|
|
2071
|
+
if mission_id == "corpus-ready":
|
|
2072
|
+
plan["allowedPlanActions"] = [
|
|
2073
|
+
"promote-request-to-backlog",
|
|
2074
|
+
"promote-backlog-to-task",
|
|
2075
|
+
"refresh-corpus-context",
|
|
2076
|
+
]
|
|
2077
|
+
return {"state": "ok", "message": "", "plan": plan, "catalog": cdx_mission_catalog_payload(), "status": status_payload}
|
|
2078
|
+
|
|
2079
|
+
|
|
2080
|
+
def cdx_mission_run_payload(
|
|
2081
|
+
repo_root: Path,
|
|
2082
|
+
body: dict[str, Any],
|
|
2083
|
+
*,
|
|
2084
|
+
cdx_runner: Any | None = None,
|
|
2085
|
+
git_runner: Any | None = None,
|
|
2086
|
+
which: Any | None = None,
|
|
2087
|
+
) -> dict[str, Any]:
|
|
2088
|
+
plan_payload = cdx_mission_plan_payload(repo_root, body, cdx_runner=cdx_runner, git_runner=git_runner, which=which)
|
|
2089
|
+
if plan_payload.get("state") != "ok":
|
|
2090
|
+
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}
|
|
2091
|
+
plan = plan_payload["plan"]
|
|
2092
|
+
timeout = int(plan.get("timeoutSeconds") or plan["strength"].get("timeout") or 180)
|
|
2093
|
+
process_timeout = timeout + CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS
|
|
2094
|
+
try:
|
|
2095
|
+
result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=process_timeout, runner=cdx_runner)
|
|
2096
|
+
except subprocess.TimeoutExpired:
|
|
2097
|
+
return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
|
|
2098
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
2099
|
+
return {"state": "error", "message": f"Unable to run CDX mission: {exc}", "plan": plan, "run": None}
|
|
2100
|
+
|
|
2101
|
+
parsed: Any = None
|
|
2102
|
+
if result.stdout.strip():
|
|
2103
|
+
try:
|
|
2104
|
+
parsed = json.loads(result.stdout)
|
|
2105
|
+
except json.JSONDecodeError:
|
|
2106
|
+
parsed = None
|
|
2107
|
+
parsed = _merge_cdx_mission_output(parsed)
|
|
2108
|
+
usage = _extract_cdx_usage(parsed)
|
|
2109
|
+
run_id = ""
|
|
2110
|
+
if isinstance(parsed, dict):
|
|
2111
|
+
run = parsed.get("run") if isinstance(parsed.get("run"), dict) else {}
|
|
2112
|
+
run_id = str(parsed.get("run_id") or parsed.get("runId") or run.get("run_id") or run.get("runId") or "")
|
|
2113
|
+
run_payload = {
|
|
2114
|
+
"returnCode": result.returncode,
|
|
2115
|
+
"runId": run_id,
|
|
2116
|
+
"stdout": _bounded_process_text(result.stdout or ""),
|
|
2117
|
+
"stderr": _bounded_process_text(result.stderr or ""),
|
|
2118
|
+
"parsed": parsed if isinstance(parsed, dict) else None,
|
|
2119
|
+
"usage": usage,
|
|
2120
|
+
}
|
|
2121
|
+
if result.returncode != 0:
|
|
2122
|
+
message = (result.stderr or result.stdout or "CDX mission failed.").strip().splitlines()[0]
|
|
2123
|
+
return {"state": "error", "message": message, "plan": plan, "run": run_payload}
|
|
2124
|
+
return {"state": "ok", "message": "", "plan": plan, "run": run_payload}
|
|
2125
|
+
|
|
2126
|
+
|
|
2127
|
+
def cdx_mission_apply_plan_payload(repo_root: Path, body: dict[str, Any], *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
2128
|
+
tool_which = which or shutil.which
|
|
2129
|
+
if not tool_which("logics-manager"):
|
|
2130
|
+
return {"state": "unavailable", "message": "logics-manager executable is not available on PATH.", "results": []}
|
|
2131
|
+
actions = body.get("actions") if isinstance(body.get("actions"), list) else []
|
|
2132
|
+
if not actions:
|
|
2133
|
+
return {"state": "error", "message": "No corpus plan actions were provided.", "results": []}
|
|
2134
|
+
|
|
2135
|
+
allowed: dict[str, list[str]] = {
|
|
2136
|
+
"promote-request-to-backlog": ["flow", "promote", "request-to-backlog"],
|
|
2137
|
+
"promote-backlog-to-task": ["flow", "promote", "backlog-to-task"],
|
|
2138
|
+
"refresh-corpus-context": ["sync", "refresh-mermaid-signatures"],
|
|
2139
|
+
}
|
|
2140
|
+
results: list[dict[str, Any]] = []
|
|
2141
|
+
for action in actions:
|
|
2142
|
+
if not isinstance(action, dict):
|
|
2143
|
+
return {"state": "error", "message": "Corpus plan actions must be objects.", "results": results}
|
|
2144
|
+
action_type = str(action.get("type") or "")
|
|
2145
|
+
command = allowed.get(action_type)
|
|
2146
|
+
if command is None:
|
|
2147
|
+
return {"state": "error", "message": f"Unsupported corpus plan action: {action_type}", "results": results}
|
|
2148
|
+
target = str(action.get("target") or "").strip()
|
|
2149
|
+
args = [*command]
|
|
2150
|
+
if target and action_type != "refresh-corpus-context":
|
|
2151
|
+
if not re.match(r"^[A-Za-z0-9_.:/-]{1,160}$", target):
|
|
2152
|
+
return {"state": "error", "message": "Invalid corpus plan action target.", "results": results}
|
|
2153
|
+
args.append(target)
|
|
2154
|
+
try:
|
|
2155
|
+
result = _run_logics_command(repo_root, args, runner=runner)
|
|
2156
|
+
except subprocess.TimeoutExpired:
|
|
2157
|
+
return {"state": "timeout", "message": "Logics corpus plan application timed out.", "results": results}
|
|
2158
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
2159
|
+
return {"state": "error", "message": f"Unable to apply corpus plan action: {exc}", "results": results}
|
|
2160
|
+
item = {
|
|
2161
|
+
"type": action_type,
|
|
2162
|
+
"target": target,
|
|
2163
|
+
"command": ["logics-manager", *args],
|
|
2164
|
+
"returnCode": result.returncode,
|
|
2165
|
+
"stdout": _bounded_process_text(result.stdout or "", 4000),
|
|
2166
|
+
"stderr": _bounded_process_text(result.stderr or "", 4000),
|
|
2167
|
+
}
|
|
2168
|
+
results.append(item)
|
|
2169
|
+
if result.returncode != 0:
|
|
2170
|
+
message = (result.stderr or result.stdout or "Corpus plan action failed.").strip().splitlines()[0]
|
|
2171
|
+
return {"state": "error", "message": message, "results": results}
|
|
2172
|
+
return {"state": "ok", "message": "", "results": results}
|
|
2173
|
+
|
|
2174
|
+
|
|
1147
2175
|
def _slugify_viewer_doc(text: str) -> str:
|
|
1148
2176
|
slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
|
1149
2177
|
return slug[:80] or "cdx_code_review_findings"
|
|
@@ -1164,17 +2192,68 @@ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, An
|
|
|
1164
2192
|
report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
|
|
1165
2193
|
run = report.get("run") if isinstance(report.get("run"), dict) else {}
|
|
1166
2194
|
task_report = report.get("task_report") if isinstance(report.get("task_report"), dict) else {}
|
|
2195
|
+
parsed = report.get("parsed") if isinstance(report.get("parsed"), dict) else {}
|
|
2196
|
+
mission_output = next(
|
|
2197
|
+
(
|
|
2198
|
+
candidate
|
|
2199
|
+
for candidate in (
|
|
2200
|
+
report.get("missionOutput"),
|
|
2201
|
+
report.get("mission_output"),
|
|
2202
|
+
parsed.get("missionOutput"),
|
|
2203
|
+
parsed.get("mission_output"),
|
|
2204
|
+
run.get("missionOutput"),
|
|
2205
|
+
run.get("mission_output"),
|
|
2206
|
+
task_report.get("missionOutput"),
|
|
2207
|
+
task_report.get("mission_output"),
|
|
2208
|
+
)
|
|
2209
|
+
if isinstance(candidate, dict)
|
|
2210
|
+
),
|
|
2211
|
+
{},
|
|
2212
|
+
)
|
|
1167
2213
|
run_id = str(run.get("run_id") or task_report.get("run_id") or "unknown")
|
|
2214
|
+
task_kind = str(task_report.get("kind") or run.get("kind") or "assistant")
|
|
1168
2215
|
findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
|
|
1169
|
-
|
|
2216
|
+
if not findings and isinstance(mission_output.get("findings"), list):
|
|
2217
|
+
findings = mission_output["findings"]
|
|
2218
|
+
recommendations = mission_output.get("recommendations") if isinstance(mission_output.get("recommendations"), list) else []
|
|
2219
|
+
request_files = mission_output.get("requestFiles") if isinstance(mission_output.get("requestFiles"), list) else []
|
|
2220
|
+
actionable_fixes = mission_output.get("actionableFixes") if isinstance(mission_output.get("actionableFixes"), list) else []
|
|
2221
|
+
release_plan = mission_output.get("releasePlan") if isinstance(mission_output.get("releasePlan"), list) else []
|
|
2222
|
+
if task_kind == "code-review":
|
|
2223
|
+
title = f"Address CDX code review findings for {run_id}"
|
|
2224
|
+
theme = "Code review follow-up"
|
|
2225
|
+
need = f"Follow up on CDX code-review run `{run_id}`."
|
|
2226
|
+
elif task_kind == "full-audit":
|
|
2227
|
+
title = f"Address CDX audit findings for {run_id}"
|
|
2228
|
+
theme = "Audit follow-up"
|
|
2229
|
+
need = f"Follow up on CDX full-audit run `{run_id}`."
|
|
2230
|
+
else:
|
|
2231
|
+
title = f"Address CDX {task_kind} follow-up for {run_id}"
|
|
2232
|
+
theme = "CDX mission follow-up"
|
|
2233
|
+
need = f"Follow up on CDX `{task_kind}` run `{run_id}`."
|
|
1170
2234
|
ref = _next_viewer_request_ref(repo_root, title)
|
|
1171
2235
|
request_dir = repo_root / "logics" / "request"
|
|
1172
2236
|
request_dir.mkdir(parents=True, exist_ok=True)
|
|
1173
2237
|
rel_path = f"logics/request/{ref}.md"
|
|
1174
2238
|
path = repo_root / rel_path
|
|
2239
|
+
|
|
2240
|
+
def _item_message(item: Any, fallback: str) -> str:
|
|
2241
|
+
if isinstance(item, dict):
|
|
2242
|
+
title_value = item.get("title") or item.get("message") or item.get("summary") or item.get("path") or fallback
|
|
2243
|
+
details = []
|
|
2244
|
+
if item.get("purpose"):
|
|
2245
|
+
details.append(f"purpose: {item['purpose']}")
|
|
2246
|
+
if item.get("command"):
|
|
2247
|
+
details.append(f"command: `{item['command']}`")
|
|
2248
|
+
if item.get("risk"):
|
|
2249
|
+
details.append(f"risk: {item['risk']}")
|
|
2250
|
+
return f"{title_value}" + (f" ({'; '.join(details)})" if details else "")
|
|
2251
|
+
return str(item or fallback)
|
|
2252
|
+
|
|
1175
2253
|
finding_lines = []
|
|
1176
2254
|
for index, finding in enumerate(findings, start=1):
|
|
1177
2255
|
if not isinstance(finding, dict):
|
|
2256
|
+
finding_lines.append(f"- F{index}: {finding}")
|
|
1178
2257
|
continue
|
|
1179
2258
|
location = finding.get("path") or finding.get("file") or "unknown path"
|
|
1180
2259
|
if finding.get("line"):
|
|
@@ -1184,21 +2263,36 @@ def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, An
|
|
|
1184
2263
|
finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
|
|
1185
2264
|
if not finding_lines:
|
|
1186
2265
|
finding_lines.append("- No structured findings were reported. Review the CDX artifacts linked below.")
|
|
2266
|
+
follow_up_lines = []
|
|
2267
|
+
for label, values in (
|
|
2268
|
+
("Recommendation", recommendations),
|
|
2269
|
+
("Request file", request_files),
|
|
2270
|
+
("Actionable fix", actionable_fixes),
|
|
2271
|
+
("Release plan", release_plan),
|
|
2272
|
+
):
|
|
2273
|
+
for index, value in enumerate(values, start=1):
|
|
2274
|
+
follow_up_lines.append(f"- {label} {index}: {_item_message(value, label)}")
|
|
2275
|
+
if not follow_up_lines:
|
|
2276
|
+
follow_up_lines.append("- Review CDX output and split any actionable follow-up into tasks before implementation.")
|
|
2277
|
+
summary = task_report.get("summary") or mission_output.get("summary") or "No structured summary provided."
|
|
1187
2278
|
text = "\n".join([
|
|
1188
2279
|
f"## {ref} - {title}",
|
|
1189
2280
|
"> Status: Draft",
|
|
1190
2281
|
"> Understanding: 70%",
|
|
1191
2282
|
"> Confidence: 70%",
|
|
1192
2283
|
"> Complexity: Medium",
|
|
1193
|
-
"> Theme:
|
|
2284
|
+
f"> Theme: {theme}",
|
|
1194
2285
|
"",
|
|
1195
2286
|
"# Needs",
|
|
1196
|
-
f"-
|
|
1197
|
-
f"- Summary: {
|
|
2287
|
+
f"- {need}",
|
|
2288
|
+
f"- Summary: {summary}",
|
|
1198
2289
|
"",
|
|
1199
2290
|
"# Findings",
|
|
1200
2291
|
*finding_lines,
|
|
1201
2292
|
"",
|
|
2293
|
+
"# Follow-up",
|
|
2294
|
+
*follow_up_lines,
|
|
2295
|
+
"",
|
|
1202
2296
|
"# Traceability",
|
|
1203
2297
|
f"- CDX run id: `{run_id}`",
|
|
1204
2298
|
f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
|
|
@@ -1224,6 +2318,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
1224
2318
|
repo_root: Path,
|
|
1225
2319
|
*,
|
|
1226
2320
|
auto_refresh_interval_seconds: int = 15,
|
|
2321
|
+
auto_refresh_interval_forced: bool = False,
|
|
1227
2322
|
):
|
|
1228
2323
|
self.launch_repo_root = repo_root.resolve()
|
|
1229
2324
|
self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
|
|
@@ -1231,6 +2326,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
1231
2326
|
self.active_project_id = _viewer_project_id(self.launch_repo_root)
|
|
1232
2327
|
self.repo_root = self.launch_repo_root
|
|
1233
2328
|
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
2329
|
+
self.auto_refresh_interval_forced = auto_refresh_interval_forced
|
|
1234
2330
|
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
1235
2331
|
|
|
1236
2332
|
def project_registry_payload(self) -> list[dict[str, Any]]:
|
|
@@ -1241,6 +2337,7 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
1241
2337
|
self.repo_root,
|
|
1242
2338
|
selected_id=selected_id,
|
|
1243
2339
|
auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
|
|
2340
|
+
auto_refresh_interval_forced=self.auto_refresh_interval_forced,
|
|
1244
2341
|
projects=self.project_registry_payload(),
|
|
1245
2342
|
)
|
|
1246
2343
|
|
|
@@ -1362,6 +2459,37 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1362
2459
|
cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
|
|
1363
2460
|
self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
|
|
1364
2461
|
return
|
|
2462
|
+
if route == "/api/git-file-preview":
|
|
2463
|
+
params = parse_qs(parsed.query)
|
|
2464
|
+
rel_path = params.get("path", [""])[0]
|
|
2465
|
+
self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
|
|
2466
|
+
return
|
|
2467
|
+
if route == "/api/workspace-tree":
|
|
2468
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
2469
|
+
try:
|
|
2470
|
+
self._send_json({"ok": True, "payload": workspace_tree_payload(self.server.repo_root, rel_path)})
|
|
2471
|
+
except ValueError as exc:
|
|
2472
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
2473
|
+
return
|
|
2474
|
+
if route == "/api/workspace-preview":
|
|
2475
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
2476
|
+
try:
|
|
2477
|
+
self._send_json({"ok": True, "payload": workspace_preview_payload(self.server.repo_root, rel_path)})
|
|
2478
|
+
except ValueError as exc:
|
|
2479
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
2480
|
+
return
|
|
2481
|
+
if route == "/api/workspace-file":
|
|
2482
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
2483
|
+
try:
|
|
2484
|
+
payload = workspace_preview_payload(self.server.repo_root, rel_path)
|
|
2485
|
+
if payload.get("state") != "image":
|
|
2486
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Workspace file is not an image preview.")
|
|
2487
|
+
return
|
|
2488
|
+
_normalized, absolute = _resolve_workspace_path(self.server.repo_root, rel_path)
|
|
2489
|
+
self._serve_file(absolute)
|
|
2490
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2491
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2492
|
+
return
|
|
1365
2493
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
1366
2494
|
|
|
1367
2495
|
def do_POST(self) -> None:
|
|
@@ -1413,6 +2541,33 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1413
2541
|
except OSError as exc:
|
|
1414
2542
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1415
2543
|
return
|
|
2544
|
+
if parsed.path == "/api/cdx-mission-plan":
|
|
2545
|
+
try:
|
|
2546
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2547
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2548
|
+
body = json.loads(raw_body or "{}")
|
|
2549
|
+
self._send_json({"ok": True, "payload": cdx_mission_plan_payload(self.server.repo_root, body)})
|
|
2550
|
+
except json.JSONDecodeError:
|
|
2551
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2552
|
+
return
|
|
2553
|
+
if parsed.path == "/api/cdx-mission-run":
|
|
2554
|
+
try:
|
|
2555
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2556
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2557
|
+
body = json.loads(raw_body or "{}")
|
|
2558
|
+
self._send_json({"ok": True, "payload": cdx_mission_run_payload(self.server.repo_root, body)})
|
|
2559
|
+
except json.JSONDecodeError:
|
|
2560
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2561
|
+
return
|
|
2562
|
+
if parsed.path == "/api/cdx-mission-apply-plan":
|
|
2563
|
+
try:
|
|
2564
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2565
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2566
|
+
body = json.loads(raw_body or "{}")
|
|
2567
|
+
self._send_json({"ok": True, "payload": cdx_mission_apply_plan_payload(self.server.repo_root, body)})
|
|
2568
|
+
except json.JSONDecodeError:
|
|
2569
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2570
|
+
return
|
|
1416
2571
|
if parsed.path == "/api/edit":
|
|
1417
2572
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
1418
2573
|
try:
|
|
@@ -1422,6 +2577,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1422
2577
|
except OSError as exc:
|
|
1423
2578
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1424
2579
|
return
|
|
2580
|
+
if parsed.path == "/api/open-file":
|
|
2581
|
+
try:
|
|
2582
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2583
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2584
|
+
body = json.loads(raw_body or "{}")
|
|
2585
|
+
self._send_json({"ok": True, "payload": open_file_payload(self.server.repo_root, str(body.get("path", "")))})
|
|
2586
|
+
except json.JSONDecodeError:
|
|
2587
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2588
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2589
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2590
|
+
except OSError as exc:
|
|
2591
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
2592
|
+
return
|
|
2593
|
+
if parsed.path == "/api/file-preview":
|
|
2594
|
+
try:
|
|
2595
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2596
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2597
|
+
body = json.loads(raw_body or "{}")
|
|
2598
|
+
self._send_json({"ok": True, "payload": file_preview_payload(self.server.repo_root, str(body.get("path", "")))})
|
|
2599
|
+
except json.JSONDecodeError:
|
|
2600
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2601
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
2602
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2603
|
+
except OSError as exc:
|
|
2604
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
2605
|
+
return
|
|
1425
2606
|
if parsed.path == "/api/open-repo-folder":
|
|
1426
2607
|
try:
|
|
1427
2608
|
self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
|
|
@@ -1437,11 +2618,13 @@ def create_viewer_server(
|
|
|
1437
2618
|
port: int = 8765,
|
|
1438
2619
|
*,
|
|
1439
2620
|
auto_refresh_interval_seconds: int = 15,
|
|
2621
|
+
auto_refresh_interval_forced: bool = False,
|
|
1440
2622
|
) -> LogicsViewerServer:
|
|
1441
2623
|
return LogicsViewerServer(
|
|
1442
2624
|
(host, port),
|
|
1443
2625
|
repo_root,
|
|
1444
2626
|
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
|
|
2627
|
+
auto_refresh_interval_forced=auto_refresh_interval_forced,
|
|
1445
2628
|
)
|
|
1446
2629
|
|
|
1447
2630
|
|
|
@@ -1489,7 +2672,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1489
2672
|
parser.add_argument(
|
|
1490
2673
|
"--refresh-interval",
|
|
1491
2674
|
type=int,
|
|
1492
|
-
default=
|
|
2675
|
+
default=None,
|
|
1493
2676
|
help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
|
|
1494
2677
|
)
|
|
1495
2678
|
parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
|
|
@@ -1502,7 +2685,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
1502
2685
|
def main(argv: list[str]) -> int:
|
|
1503
2686
|
args = build_parser().parse_args(argv)
|
|
1504
2687
|
repo_root = find_repo_root(Path.cwd())
|
|
1505
|
-
|
|
2688
|
+
refresh_interval_forced = args.refresh_interval is not None
|
|
2689
|
+
refresh_interval = args.refresh_interval if args.refresh_interval is not None else 15
|
|
2690
|
+
if refresh_interval <= 0:
|
|
1506
2691
|
raise SystemExit("--refresh-interval must be a positive number of seconds.")
|
|
1507
2692
|
if args.read and not args.focus:
|
|
1508
2693
|
raise SystemExit("--read requires --focus.")
|
|
@@ -1514,7 +2699,8 @@ def main(argv: list[str]) -> int:
|
|
|
1514
2699
|
repo_root,
|
|
1515
2700
|
host=args.host,
|
|
1516
2701
|
port=args.port,
|
|
1517
|
-
auto_refresh_interval_seconds=
|
|
2702
|
+
auto_refresh_interval_seconds=refresh_interval,
|
|
2703
|
+
auto_refresh_interval_forced=refresh_interval_forced,
|
|
1518
2704
|
)
|
|
1519
2705
|
host, port = server.server_address[:2]
|
|
1520
2706
|
url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
@@ -1526,7 +2712,7 @@ def main(argv: list[str]) -> int:
|
|
|
1526
2712
|
focus=focus,
|
|
1527
2713
|
network_url=network_url,
|
|
1528
2714
|
bind_host=str(host),
|
|
1529
|
-
auto_refresh_interval_seconds=
|
|
2715
|
+
auto_refresh_interval_seconds=refresh_interval,
|
|
1530
2716
|
),
|
|
1531
2717
|
flush=True,
|
|
1532
2718
|
)
|