@grifhinz/logics-manager 2.6.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 +1597 -100
- package/clients/viewer/index.html +11 -4
- package/clients/viewer/viewer.css +402 -1
- package/logics_manager/assist.py +1 -0
- package/logics_manager/cli.py +5 -3
- package/logics_manager/flow.py +67 -3
- package/logics_manager/lint.py +11 -7
- package/logics_manager/sync.py +47 -19
- package/logics_manager/viewer.py +1327 -25
- package/package.json +1 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import mimetypes
|
|
6
7
|
import os
|
|
@@ -20,6 +21,7 @@ from typing import Any
|
|
|
20
21
|
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
|
|
21
22
|
|
|
22
23
|
from .audit import audit_payload
|
|
24
|
+
from .bootstrap import bootstrap_payload
|
|
23
25
|
from .config import find_repo_root
|
|
24
26
|
from .lint import lint_payload
|
|
25
27
|
from .update_check import get_update_info
|
|
@@ -42,6 +44,104 @@ DOC_FAMILIES = (
|
|
|
42
44
|
)
|
|
43
45
|
|
|
44
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
|
|
45
145
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
46
146
|
PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
|
|
47
147
|
VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
|
|
@@ -342,22 +442,28 @@ def viewer_data_payload(
|
|
|
342
442
|
selected_id: str | None = None,
|
|
343
443
|
*,
|
|
344
444
|
auto_refresh_interval_seconds: int = 15,
|
|
445
|
+
projects: list[dict[str, Any]] | None = None,
|
|
345
446
|
) -> dict[str, Any]:
|
|
447
|
+
capabilities = viewer_project_capabilities(repo_root)
|
|
448
|
+
active_root = repo_root.resolve()
|
|
449
|
+
has_logics = capabilities["logics"]["available"] is True
|
|
346
450
|
return {
|
|
347
|
-
"root": str(
|
|
348
|
-
"repoName":
|
|
451
|
+
"root": str(active_root),
|
|
452
|
+
"repoName": active_root.name,
|
|
349
453
|
"repository": {
|
|
350
|
-
"root": str(
|
|
454
|
+
"root": str(active_root),
|
|
351
455
|
"githubUrl": github_repo_url(repo_root),
|
|
352
456
|
},
|
|
457
|
+
"capabilities": capabilities,
|
|
458
|
+
"projects": projects if projects is not None else viewer_project_registry(repo_root),
|
|
353
459
|
"autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
|
|
354
460
|
"items": collect_viewer_items(repo_root),
|
|
355
461
|
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
356
462
|
"selectedId": selected_id,
|
|
357
463
|
"changedPaths": [],
|
|
358
464
|
"canResetProjectRoot": False,
|
|
359
|
-
"canBootstrapLogics":
|
|
360
|
-
"bootstrapLogicsTitle": "
|
|
465
|
+
"canBootstrapLogics": not has_logics,
|
|
466
|
+
"bootstrapLogicsTitle": "Bootstrap Logics in this project." if not has_logics else "Logics is already bootstrapped.",
|
|
361
467
|
"canLaunchCodex": False,
|
|
362
468
|
"canLaunchClaude": False,
|
|
363
469
|
"canRepairLogicsKit": False,
|
|
@@ -366,6 +472,155 @@ def viewer_data_payload(
|
|
|
366
472
|
}
|
|
367
473
|
|
|
368
474
|
|
|
475
|
+
def _viewer_project_id(repo_root: Path) -> str:
|
|
476
|
+
normalized = str(repo_root.resolve())
|
|
477
|
+
return hashlib.sha1(normalized.encode("utf-8")).hexdigest()[:12]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _looks_like_viewer_project(path: Path) -> bool:
|
|
481
|
+
if not path.is_dir():
|
|
482
|
+
return False
|
|
483
|
+
return any((path / marker).exists() for marker in ("logics", ".git", "package.json", "pyproject.toml", "logics.yaml"))
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def discover_viewer_project_roots(repo_root: Path, *, max_projects: int = 40) -> list[Path]:
|
|
487
|
+
active = repo_root.resolve()
|
|
488
|
+
candidates: list[Path] = [active]
|
|
489
|
+
parent = active.parent
|
|
490
|
+
try:
|
|
491
|
+
siblings = sorted(parent.iterdir(), key=lambda path: path.name.lower())
|
|
492
|
+
except OSError:
|
|
493
|
+
siblings = []
|
|
494
|
+
for sibling in siblings:
|
|
495
|
+
try:
|
|
496
|
+
resolved = sibling.resolve()
|
|
497
|
+
except OSError:
|
|
498
|
+
continue
|
|
499
|
+
if resolved == active or not _looks_like_viewer_project(resolved):
|
|
500
|
+
continue
|
|
501
|
+
candidates.append(resolved)
|
|
502
|
+
if len(candidates) >= max_projects:
|
|
503
|
+
break
|
|
504
|
+
|
|
505
|
+
unique: dict[str, Path] = {}
|
|
506
|
+
for candidate in candidates:
|
|
507
|
+
unique[str(candidate)] = candidate
|
|
508
|
+
return list(unique.values())
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def viewer_project_entry(repo_root: Path, *, active_root: Path | None = None) -> dict[str, Any]:
|
|
512
|
+
root = repo_root.resolve()
|
|
513
|
+
active = active_root.resolve() if active_root else root
|
|
514
|
+
has_logics = (root / "logics").is_dir()
|
|
515
|
+
available = root.is_dir()
|
|
516
|
+
return {
|
|
517
|
+
"id": _viewer_project_id(root),
|
|
518
|
+
"name": root.name,
|
|
519
|
+
"root": str(root),
|
|
520
|
+
"active": root == active,
|
|
521
|
+
"available": available,
|
|
522
|
+
"hasLogics": has_logics,
|
|
523
|
+
"message": "Logics corpus found." if has_logics else "No Logics corpus found.",
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def viewer_project_registry(repo_root: Path, *, project_roots: list[Path] | None = None) -> list[dict[str, Any]]:
|
|
528
|
+
active = repo_root.resolve()
|
|
529
|
+
roots = project_roots if project_roots is not None else discover_viewer_project_roots(active)
|
|
530
|
+
return [viewer_project_entry(root, active_root=active) for root in roots]
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _viewer_capability(state: str, *, available: bool, message: str, detail: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
534
|
+
payload: dict[str, Any] = {
|
|
535
|
+
"state": state,
|
|
536
|
+
"available": available,
|
|
537
|
+
"message": message,
|
|
538
|
+
}
|
|
539
|
+
if detail:
|
|
540
|
+
payload["detail"] = detail
|
|
541
|
+
return payload
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _git_is_repository(repo_root: Path, *, runner: Any | None = None) -> bool | None:
|
|
545
|
+
try:
|
|
546
|
+
result = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
547
|
+
except (OSError, subprocess.SubprocessError):
|
|
548
|
+
return None
|
|
549
|
+
if result.returncode != 0:
|
|
550
|
+
return False
|
|
551
|
+
return result.stdout.strip().lower() == "true"
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def viewer_project_capabilities(
|
|
555
|
+
repo_root: Path,
|
|
556
|
+
*,
|
|
557
|
+
git_runner: Any | None = None,
|
|
558
|
+
which: Any | None = None,
|
|
559
|
+
) -> dict[str, Any]:
|
|
560
|
+
which_command = which or shutil.which
|
|
561
|
+
logics_dir = repo_root / "logics"
|
|
562
|
+
has_logics = logics_dir.is_dir()
|
|
563
|
+
git_path = which_command("git")
|
|
564
|
+
cdx_path = which_command("cdx")
|
|
565
|
+
|
|
566
|
+
if has_logics:
|
|
567
|
+
logics = _viewer_capability("ready", available=True, message="Logics corpus found.")
|
|
568
|
+
else:
|
|
569
|
+
logics = _viewer_capability("missing", available=False, message="No Logics corpus found.")
|
|
570
|
+
|
|
571
|
+
if not git_path:
|
|
572
|
+
git = _viewer_capability("unavailable", available=False, message="Git executable is not available.")
|
|
573
|
+
github_url = ""
|
|
574
|
+
has_workflows = False
|
|
575
|
+
else:
|
|
576
|
+
is_repo = _git_is_repository(repo_root, runner=git_runner)
|
|
577
|
+
if is_repo is True:
|
|
578
|
+
git = _viewer_capability("ready", available=True, message="Git repository detected.")
|
|
579
|
+
github_url = github_repo_url(repo_root, runner=git_runner, which=which_command)
|
|
580
|
+
has_workflows = _has_github_actions_workflows(repo_root)
|
|
581
|
+
elif is_repo is False:
|
|
582
|
+
git = _viewer_capability("missing", available=False, message="Project is not a Git repository.")
|
|
583
|
+
github_url = ""
|
|
584
|
+
has_workflows = False
|
|
585
|
+
else:
|
|
586
|
+
git = _viewer_capability("error", available=False, message="Unable to inspect Git repository state.")
|
|
587
|
+
github_url = ""
|
|
588
|
+
has_workflows = False
|
|
589
|
+
|
|
590
|
+
if not github_url:
|
|
591
|
+
ci = _viewer_capability("hidden", available=False, message="No GitHub remote detected for this project.")
|
|
592
|
+
elif not has_workflows:
|
|
593
|
+
ci = _viewer_capability("hidden", available=False, message="No GitHub Actions workflows detected for this project.")
|
|
594
|
+
elif not which_command("gh"):
|
|
595
|
+
ci = _viewer_capability("unavailable", available=False, message="GitHub CLI is not available.")
|
|
596
|
+
else:
|
|
597
|
+
ci = _viewer_capability(
|
|
598
|
+
"ready",
|
|
599
|
+
available=True,
|
|
600
|
+
message="GitHub Actions can be inspected.",
|
|
601
|
+
detail={"githubUrl": github_url},
|
|
602
|
+
)
|
|
603
|
+
|
|
604
|
+
if cdx_path:
|
|
605
|
+
cdx = _viewer_capability("ready", available=True, message="CDX executable detected.")
|
|
606
|
+
cdx_runs = _viewer_capability(
|
|
607
|
+
"unsupported",
|
|
608
|
+
available=False,
|
|
609
|
+
message="CDX assistant run registry is not available yet.",
|
|
610
|
+
)
|
|
611
|
+
else:
|
|
612
|
+
cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
|
|
613
|
+
cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
"logics": logics,
|
|
617
|
+
"git": git,
|
|
618
|
+
"ci": ci,
|
|
619
|
+
"cdx": cdx,
|
|
620
|
+
"cdxRuns": cdx_runs,
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
|
|
369
624
|
def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
|
|
370
625
|
normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
|
|
371
626
|
return {
|
|
@@ -396,6 +651,54 @@ def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = N
|
|
|
396
651
|
}
|
|
397
652
|
|
|
398
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
|
+
|
|
399
702
|
def open_repo_folder_payload(repo_root: Path, *, launcher: Any | None = None) -> dict[str, str]:
|
|
400
703
|
root = repo_root.resolve()
|
|
401
704
|
command = _system_editor_command(root)
|
|
@@ -427,6 +730,24 @@ def _run_read_only_cdx(repo_root: Path, args: list[str], *, runner: Any | None =
|
|
|
427
730
|
return cdx_runner(command, cwd=repo_root, text=True, capture_output=True, timeout=5)
|
|
428
731
|
|
|
429
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
|
+
|
|
430
751
|
def _run_read_only_gh(repo_root: Path, args: list[str], *, runner: Any | None = None) -> subprocess.CompletedProcess[str]:
|
|
431
752
|
command = ["gh", *args]
|
|
432
753
|
gh_runner = runner or subprocess.run
|
|
@@ -554,7 +875,11 @@ def _parse_git_branch_line(line: str) -> dict[str, Any]:
|
|
|
554
875
|
}
|
|
555
876
|
|
|
556
877
|
|
|
557
|
-
|
|
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]]:
|
|
558
883
|
commits: list[dict[str, str]] = []
|
|
559
884
|
for line in output.splitlines():
|
|
560
885
|
parts = line.split("\x1f")
|
|
@@ -570,6 +895,8 @@ def _parse_recent_git_commits(output: str) -> list[dict[str, str]]:
|
|
|
570
895
|
"refs": _sanitize_git_ref(refs),
|
|
571
896
|
}
|
|
572
897
|
)
|
|
898
|
+
if limit is not None and len(commits) >= limit:
|
|
899
|
+
break
|
|
573
900
|
return commits
|
|
574
901
|
|
|
575
902
|
|
|
@@ -656,7 +983,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
656
983
|
commit = _run_read_only_git(repo_root, ["log", "-1", "--pretty=format:%h %s"], runner=runner)
|
|
657
984
|
recent_commits = _run_read_only_git(
|
|
658
985
|
repo_root,
|
|
659
|
-
["log", "-
|
|
986
|
+
["log", f"-{GIT_HISTORY_FETCH_LIMIT}", "--date=short", "--pretty=format:%h%x1f%s%x1f%an%x1f%ad%x1f%D"],
|
|
660
987
|
runner=runner,
|
|
661
988
|
)
|
|
662
989
|
unpushed = _git_unpushed_commit_count(repo_root, runner=runner)
|
|
@@ -683,6 +1010,7 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
683
1010
|
counts = {key: len(value) for key, value in groups.items()}
|
|
684
1011
|
uncommitted_files = _count_unique_git_status_paths(groups)
|
|
685
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 []
|
|
686
1014
|
return {
|
|
687
1015
|
"state": "ok",
|
|
688
1016
|
**branch_info,
|
|
@@ -703,10 +1031,18 @@ def git_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
703
1031
|
},
|
|
704
1032
|
"groups": groups,
|
|
705
1033
|
"latestCommit": (commit.stdout.strip() if commit.returncode == 0 else "")[:300],
|
|
706
|
-
"recentCommits":
|
|
1034
|
+
"recentCommits": parsed_recent_commits[:GIT_HISTORY_DISPLAY_LIMIT],
|
|
1035
|
+
"recentCommitsHasMore": len(parsed_recent_commits) > GIT_HISTORY_DISPLAY_LIMIT,
|
|
707
1036
|
}
|
|
708
1037
|
|
|
709
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
|
+
|
|
710
1046
|
def git_diff_payload(
|
|
711
1047
|
repo_root: Path,
|
|
712
1048
|
rel_path: str,
|
|
@@ -719,8 +1055,8 @@ def git_diff_payload(
|
|
|
719
1055
|
git_which = which or shutil.which
|
|
720
1056
|
if not git_which("git"):
|
|
721
1057
|
return {"state": "unavailable", "message": "Git is not available on PATH."}
|
|
722
|
-
normalized =
|
|
723
|
-
if not normalized
|
|
1058
|
+
normalized = _normalize_git_file_path(rel_path)
|
|
1059
|
+
if not normalized:
|
|
724
1060
|
return {"state": "error", "message": "Unsafe Git path."}
|
|
725
1061
|
try:
|
|
726
1062
|
inside = _run_read_only_git(repo_root, ["rev-parse", "--is-inside-work-tree"], runner=runner)
|
|
@@ -755,6 +1091,71 @@ def git_diff_payload(
|
|
|
755
1091
|
}
|
|
756
1092
|
|
|
757
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
|
+
|
|
758
1159
|
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
759
1160
|
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
760
1161
|
commands = {
|
|
@@ -789,11 +1190,41 @@ def _ci_badge_state(status: str, conclusion: str) -> str:
|
|
|
789
1190
|
return "unknown"
|
|
790
1191
|
|
|
791
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
|
+
|
|
792
1222
|
def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
|
|
793
1223
|
status = str(run.get("status") or "")
|
|
794
1224
|
conclusion = str(run.get("conclusion") or "")
|
|
795
1225
|
commit = run.get("head_commit") if isinstance(run.get("head_commit"), dict) else {}
|
|
796
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()
|
|
797
1228
|
return {
|
|
798
1229
|
"id": run.get("id"),
|
|
799
1230
|
"name": str(run.get("name") or run.get("display_title") or "GitHub Actions"),
|
|
@@ -808,7 +1239,7 @@ def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict
|
|
|
808
1239
|
"createdAt": str(run.get("created_at") or ""),
|
|
809
1240
|
"updatedAt": str(run.get("updated_at") or ""),
|
|
810
1241
|
"runStartedAt": str(run.get("run_started_at") or ""),
|
|
811
|
-
"commitMessage":
|
|
1242
|
+
"commitMessage": commit_lines[0][:240] if commit_lines else "",
|
|
812
1243
|
"author": str(author.get("name") or ""),
|
|
813
1244
|
"matchSource": match_source,
|
|
814
1245
|
}
|
|
@@ -864,7 +1295,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
|
|
|
864
1295
|
context = _current_git_ci_context(repo_root, runner=git_runner)
|
|
865
1296
|
branch = context.get("branch", "")
|
|
866
1297
|
head_sha = context.get("headSha", "")
|
|
867
|
-
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=
|
|
1298
|
+
endpoint = f"repos/{owner}/{repo}/actions/runs?per_page=30"
|
|
868
1299
|
if branch:
|
|
869
1300
|
endpoint = f"{endpoint}&branch={quote(branch, safe='')}"
|
|
870
1301
|
try:
|
|
@@ -886,10 +1317,7 @@ def ci_status_payload(repo_root: Path, *, git_runner: Any | None = None, gh_runn
|
|
|
886
1317
|
if not runs:
|
|
887
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": []}
|
|
888
1319
|
|
|
889
|
-
selected =
|
|
890
|
-
match_source = "head" if selected else "branch-latest"
|
|
891
|
-
if selected is None:
|
|
892
|
-
selected = runs[0]
|
|
1320
|
+
selected, match_source = _select_github_actions_run(runs, head_sha)
|
|
893
1321
|
run_payload = _parse_github_actions_run(selected, match_source=match_source)
|
|
894
1322
|
jobs: list[dict[str, str]] = []
|
|
895
1323
|
run_id = run_payload.get("id")
|
|
@@ -939,6 +1367,751 @@ def cdx_status_payload(repo_root: Path, *, runner: Any | None = None, which: Any
|
|
|
939
1367
|
return {"state": "ok", "message": "", "status": parsed}
|
|
940
1368
|
|
|
941
1369
|
|
|
1370
|
+
def cdx_runs_payload(repo_root: Path, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
1371
|
+
cdx_which = which or shutil.which
|
|
1372
|
+
if not cdx_which("cdx"):
|
|
1373
|
+
return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "runs": []}
|
|
1374
|
+
try:
|
|
1375
|
+
result = _run_read_only_cdx(repo_root, ["runs", "--json"], runner=runner)
|
|
1376
|
+
except subprocess.TimeoutExpired:
|
|
1377
|
+
return {"state": "timeout", "message": "CDX runs timed out.", "runs": []}
|
|
1378
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1379
|
+
return {"state": "error", "message": f"Unable to run CDX runs: {exc}", "runs": []}
|
|
1380
|
+
if result.returncode != 0:
|
|
1381
|
+
message = (result.stderr or result.stdout or "CDX runs failed.").strip().splitlines()[0]
|
|
1382
|
+
return {"state": "error", "message": message, "runs": []}
|
|
1383
|
+
try:
|
|
1384
|
+
parsed = json.loads(result.stdout or "{}")
|
|
1385
|
+
except json.JSONDecodeError:
|
|
1386
|
+
return {"state": "invalid-json", "message": "CDX runs returned invalid JSON.", "runs": []}
|
|
1387
|
+
runs = parsed.get("runs") if isinstance(parsed, dict) else None
|
|
1388
|
+
if not isinstance(runs, list):
|
|
1389
|
+
return {"state": "invalid-json", "message": "CDX runs JSON must include a runs array.", "runs": []}
|
|
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}
|
|
1402
|
+
|
|
1403
|
+
|
|
1404
|
+
def cdx_run_report_payload(repo_root: Path, run_id: str, *, runner: Any | None = None, which: Any | None = None) -> dict[str, Any]:
|
|
1405
|
+
cdx_which = which or shutil.which
|
|
1406
|
+
if not run_id:
|
|
1407
|
+
return {"state": "error", "message": "Missing CDX run id.", "report": None}
|
|
1408
|
+
if not cdx_which("cdx"):
|
|
1409
|
+
return {"state": "unavailable", "message": "CDX executable is not available on PATH.", "report": None}
|
|
1410
|
+
try:
|
|
1411
|
+
result = _run_read_only_cdx(repo_root, ["run-report", run_id, "--json"], runner=runner)
|
|
1412
|
+
except subprocess.TimeoutExpired:
|
|
1413
|
+
return {"state": "timeout", "message": "CDX run report timed out.", "report": None}
|
|
1414
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
1415
|
+
return {"state": "error", "message": f"Unable to run CDX run-report: {exc}", "report": None}
|
|
1416
|
+
if result.returncode != 0:
|
|
1417
|
+
message = (result.stderr or result.stdout or "CDX run-report failed.").strip().splitlines()[0]
|
|
1418
|
+
return {"state": "error", "message": message, "report": None}
|
|
1419
|
+
try:
|
|
1420
|
+
parsed = json.loads(result.stdout or "{}")
|
|
1421
|
+
except json.JSONDecodeError:
|
|
1422
|
+
return {"state": "invalid-json", "message": "CDX run-report returned invalid JSON.", "report": None}
|
|
1423
|
+
report = parsed.get("report") if isinstance(parsed, dict) else None
|
|
1424
|
+
if not isinstance(report, dict):
|
|
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
|
|
1429
|
+
return {"state": "ok", "message": "", "report": report}
|
|
1430
|
+
|
|
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
|
+
|
|
1980
|
+
def _slugify_viewer_doc(text: str) -> str:
|
|
1981
|
+
slug = re.sub(r"[^a-z0-9]+", "_", text.lower()).strip("_")
|
|
1982
|
+
return slug[:80] or "cdx_code_review_findings"
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
def _next_viewer_request_ref(repo_root: Path, title: str) -> str:
|
|
1986
|
+
request_dir = repo_root / "logics" / "request"
|
|
1987
|
+
highest = -1
|
|
1988
|
+
if request_dir.is_dir():
|
|
1989
|
+
for path in request_dir.glob("req_*.md"):
|
|
1990
|
+
match = re.match(r"^req_(\d{3})_", path.stem)
|
|
1991
|
+
if match:
|
|
1992
|
+
highest = max(highest, int(match.group(1)))
|
|
1993
|
+
return f"req_{highest + 1:03d}_{_slugify_viewer_doc(title)}"
|
|
1994
|
+
|
|
1995
|
+
|
|
1996
|
+
def create_request_from_cdx_report(repo_root: Path, report_payload: dict[str, Any]) -> dict[str, Any]:
|
|
1997
|
+
report = report_payload.get("report") if isinstance(report_payload.get("report"), dict) else report_payload
|
|
1998
|
+
run = report.get("run") if isinstance(report.get("run"), dict) else {}
|
|
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
|
+
)
|
|
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")
|
|
2020
|
+
findings = task_report.get("findings") if isinstance(task_report.get("findings"), list) else []
|
|
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}`."
|
|
2039
|
+
ref = _next_viewer_request_ref(repo_root, title)
|
|
2040
|
+
request_dir = repo_root / "logics" / "request"
|
|
2041
|
+
request_dir.mkdir(parents=True, exist_ok=True)
|
|
2042
|
+
rel_path = f"logics/request/{ref}.md"
|
|
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
|
+
|
|
2058
|
+
finding_lines = []
|
|
2059
|
+
for index, finding in enumerate(findings, start=1):
|
|
2060
|
+
if not isinstance(finding, dict):
|
|
2061
|
+
finding_lines.append(f"- F{index}: {finding}")
|
|
2062
|
+
continue
|
|
2063
|
+
location = finding.get("path") or finding.get("file") or "unknown path"
|
|
2064
|
+
if finding.get("line"):
|
|
2065
|
+
location = f"{location}:{finding['line']}"
|
|
2066
|
+
severity = finding.get("severity") or "unknown"
|
|
2067
|
+
message = finding.get("message") or finding.get("title") or "Review finding"
|
|
2068
|
+
finding_lines.append(f"- F{index} [{severity}] `{location}`: {message}")
|
|
2069
|
+
if not finding_lines:
|
|
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."
|
|
2083
|
+
text = "\n".join([
|
|
2084
|
+
f"## {ref} - {title}",
|
|
2085
|
+
"> Status: Draft",
|
|
2086
|
+
"> Understanding: 70%",
|
|
2087
|
+
"> Confidence: 70%",
|
|
2088
|
+
"> Complexity: Medium",
|
|
2089
|
+
f"> Theme: {theme}",
|
|
2090
|
+
"",
|
|
2091
|
+
"# Needs",
|
|
2092
|
+
f"- {need}",
|
|
2093
|
+
f"- Summary: {summary}",
|
|
2094
|
+
"",
|
|
2095
|
+
"# Findings",
|
|
2096
|
+
*finding_lines,
|
|
2097
|
+
"",
|
|
2098
|
+
"# Follow-up",
|
|
2099
|
+
*follow_up_lines,
|
|
2100
|
+
"",
|
|
2101
|
+
"# Traceability",
|
|
2102
|
+
f"- CDX run id: `{run_id}`",
|
|
2103
|
+
f"- Transcript: `{(report.get('artifacts') or {}).get('transcript_path') or ''}`",
|
|
2104
|
+
f"- Stdout: `{(report.get('artifacts') or {}).get('stdout_path') or ''}`",
|
|
2105
|
+
"",
|
|
2106
|
+
"# Acceptance Criteria",
|
|
2107
|
+
"- AC1: Each actionable finding is reviewed and either fixed, documented as not applicable, or split into follow-up work.",
|
|
2108
|
+
"- AC2: Validation evidence is added before closing this request.",
|
|
2109
|
+
"",
|
|
2110
|
+
])
|
|
2111
|
+
path.write_text(text, encoding="utf-8")
|
|
2112
|
+
return {"id": ref, "path": rel_path, "title": title}
|
|
2113
|
+
|
|
2114
|
+
|
|
942
2115
|
def _json_bytes(payload: Any) -> bytes:
|
|
943
2116
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
944
2117
|
|
|
@@ -951,10 +2124,35 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
951
2124
|
*,
|
|
952
2125
|
auto_refresh_interval_seconds: int = 15,
|
|
953
2126
|
):
|
|
954
|
-
self.
|
|
2127
|
+
self.launch_repo_root = repo_root.resolve()
|
|
2128
|
+
self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
|
|
2129
|
+
self.project_root_by_id = {_viewer_project_id(root): root.resolve() for root in self.project_roots}
|
|
2130
|
+
self.active_project_id = _viewer_project_id(self.launch_repo_root)
|
|
2131
|
+
self.repo_root = self.launch_repo_root
|
|
955
2132
|
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
956
2133
|
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
957
2134
|
|
|
2135
|
+
def project_registry_payload(self) -> list[dict[str, Any]]:
|
|
2136
|
+
return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
|
|
2137
|
+
|
|
2138
|
+
def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
|
|
2139
|
+
return viewer_data_payload(
|
|
2140
|
+
self.repo_root,
|
|
2141
|
+
selected_id=selected_id,
|
|
2142
|
+
auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
|
|
2143
|
+
projects=self.project_registry_payload(),
|
|
2144
|
+
)
|
|
2145
|
+
|
|
2146
|
+
def switch_project(self, project_id: str) -> dict[str, Any]:
|
|
2147
|
+
target = self.project_root_by_id.get(project_id)
|
|
2148
|
+
if target is None:
|
|
2149
|
+
raise ValueError("Unknown project id.")
|
|
2150
|
+
if not target.is_dir():
|
|
2151
|
+
raise FileNotFoundError(str(target))
|
|
2152
|
+
self.active_project_id = project_id
|
|
2153
|
+
self.repo_root = target
|
|
2154
|
+
return self.viewer_payload()
|
|
2155
|
+
|
|
958
2156
|
|
|
959
2157
|
class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
960
2158
|
server: LogicsViewerServer
|
|
@@ -1018,13 +2216,13 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1018
2216
|
self._send_json(
|
|
1019
2217
|
{
|
|
1020
2218
|
"ok": True,
|
|
1021
|
-
"payload":
|
|
1022
|
-
self.server.repo_root,
|
|
1023
|
-
auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
|
|
1024
|
-
),
|
|
2219
|
+
"payload": self.server.viewer_payload(),
|
|
1025
2220
|
}
|
|
1026
2221
|
)
|
|
1027
2222
|
return
|
|
2223
|
+
if route == "/api/projects":
|
|
2224
|
+
self._send_json({"ok": True, "payload": {"projects": self.server.project_registry_payload()}})
|
|
2225
|
+
return
|
|
1028
2226
|
if route == "/api/doc":
|
|
1029
2227
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
1030
2228
|
try:
|
|
@@ -1038,6 +2236,9 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1038
2236
|
if route == "/api/audit":
|
|
1039
2237
|
self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
|
|
1040
2238
|
return
|
|
2239
|
+
if route == "/api/capabilities":
|
|
2240
|
+
self._send_json({"ok": True, "payload": viewer_project_capabilities(self.server.repo_root)})
|
|
2241
|
+
return
|
|
1041
2242
|
if route == "/api/git-status":
|
|
1042
2243
|
self._send_json({"ok": True, "payload": git_status_payload(self.server.repo_root)})
|
|
1043
2244
|
return
|
|
@@ -1047,12 +2248,24 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1047
2248
|
if route == "/api/cdx-status":
|
|
1048
2249
|
self._send_json({"ok": True, "payload": cdx_status_payload(self.server.repo_root)})
|
|
1049
2250
|
return
|
|
2251
|
+
if route == "/api/cdx-runs":
|
|
2252
|
+
self._send_json({"ok": True, "payload": cdx_runs_payload(self.server.repo_root)})
|
|
2253
|
+
return
|
|
2254
|
+
if route == "/api/cdx-run-report":
|
|
2255
|
+
run_id = parse_qs(parsed.query).get("runId", [""])[0]
|
|
2256
|
+
self._send_json({"ok": True, "payload": cdx_run_report_payload(self.server.repo_root, run_id)})
|
|
2257
|
+
return
|
|
1050
2258
|
if route == "/api/git-diff":
|
|
1051
2259
|
params = parse_qs(parsed.query)
|
|
1052
2260
|
rel_path = params.get("path", [""])[0]
|
|
1053
2261
|
cached = params.get("cached", [""])[0].lower() in {"1", "true", "yes"}
|
|
1054
2262
|
self._send_json({"ok": True, "payload": git_diff_payload(self.server.repo_root, rel_path, cached=cached)})
|
|
1055
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
|
|
1056
2269
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
1057
2270
|
|
|
1058
2271
|
def do_POST(self) -> None:
|
|
@@ -1061,13 +2274,76 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1061
2274
|
self._send_json(
|
|
1062
2275
|
{
|
|
1063
2276
|
"ok": True,
|
|
1064
|
-
"payload":
|
|
1065
|
-
self.server.repo_root,
|
|
1066
|
-
auto_refresh_interval_seconds=self.server.auto_refresh_interval_seconds,
|
|
1067
|
-
),
|
|
2277
|
+
"payload": self.server.viewer_payload(),
|
|
1068
2278
|
}
|
|
1069
2279
|
)
|
|
1070
2280
|
return
|
|
2281
|
+
if parsed.path == "/api/switch-project":
|
|
2282
|
+
try:
|
|
2283
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2284
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2285
|
+
body = json.loads(raw_body or "{}")
|
|
2286
|
+
project_id = str(body.get("projectId") or "")
|
|
2287
|
+
self._send_json({"ok": True, "payload": self.server.switch_project(project_id)})
|
|
2288
|
+
except json.JSONDecodeError:
|
|
2289
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2290
|
+
except ValueError as exc:
|
|
2291
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
2292
|
+
except FileNotFoundError as exc:
|
|
2293
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2294
|
+
return
|
|
2295
|
+
if parsed.path == "/api/bootstrap-logics":
|
|
2296
|
+
try:
|
|
2297
|
+
bootstrap = bootstrap_payload(self.server.repo_root, check=False)
|
|
2298
|
+
self._send_json({"ok": True, "payload": self.server.viewer_payload(), "bootstrap": bootstrap})
|
|
2299
|
+
except SystemExit as exc:
|
|
2300
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, str(exc))
|
|
2301
|
+
except OSError as exc:
|
|
2302
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
2303
|
+
return
|
|
2304
|
+
if parsed.path == "/api/cdx-report-request":
|
|
2305
|
+
try:
|
|
2306
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
2307
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
2308
|
+
body = json.loads(raw_body or "{}")
|
|
2309
|
+
report_payload = cdx_run_report_payload(self.server.repo_root, str(body.get("runId") or ""))
|
|
2310
|
+
if report_payload.get("state") != "ok":
|
|
2311
|
+
self._send_error_json(HTTPStatus.BAD_GATEWAY, str(report_payload.get("message") or "Unable to load CDX report."))
|
|
2312
|
+
return
|
|
2313
|
+
created = create_request_from_cdx_report(self.server.repo_root, report_payload)
|
|
2314
|
+
self._send_json({"ok": True, "created": created, "payload": self.server.viewer_payload(selected_id=created["id"])})
|
|
2315
|
+
except json.JSONDecodeError:
|
|
2316
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
2317
|
+
except OSError as exc:
|
|
2318
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
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
|
|
1071
2347
|
if parsed.path == "/api/edit":
|
|
1072
2348
|
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
1073
2349
|
try:
|
|
@@ -1077,6 +2353,32 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
1077
2353
|
except OSError as exc:
|
|
1078
2354
|
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
1079
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
|
|
1080
2382
|
if parsed.path == "/api/open-repo-folder":
|
|
1081
2383
|
try:
|
|
1082
2384
|
self._send_json({"ok": True, "payload": open_repo_folder_payload(self.server.repo_root)})
|