@grifhinz/logics-manager 2.2.0 → 2.3.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 +60 -1
- package/VERSION +1 -1
- package/clients/README.md +9 -0
- package/clients/shared-web/media/css/board.css +658 -0
- package/clients/shared-web/media/css/details.css +457 -0
- package/clients/shared-web/media/css/layout.css +123 -0
- package/clients/shared-web/media/css/toolbar.css +576 -0
- package/clients/shared-web/media/harnessApi.js +324 -0
- package/clients/shared-web/media/hostApi.js +213 -0
- package/clients/shared-web/media/hostApiContract.js +55 -0
- package/clients/shared-web/media/icon.png +0 -0
- package/clients/shared-web/media/layoutController.js +246 -0
- package/clients/shared-web/media/logics.svg +7 -0
- package/clients/shared-web/media/logicsModel.js +910 -0
- package/clients/shared-web/media/main.css +112 -0
- package/clients/shared-web/media/main.js +3 -0
- package/clients/shared-web/media/mainApp.js +1005 -0
- package/clients/shared-web/media/mainCore.js +604 -0
- package/clients/shared-web/media/mainInteractionHandlers.js +324 -0
- package/clients/shared-web/media/mainInteractions.js +378 -0
- package/clients/shared-web/media/renderBoard.js +3 -0
- package/clients/shared-web/media/renderBoardApp.js +1339 -0
- package/clients/shared-web/media/renderDetails.js +685 -0
- package/clients/shared-web/media/renderMarkdown.js +449 -0
- package/clients/shared-web/media/toolsPanelLayout.js +172 -0
- package/clients/shared-web/media/uiStatus.js +54 -0
- package/clients/shared-web/media/webviewChrome.js +405 -0
- package/clients/shared-web/media/webviewPersistence.js +116 -0
- package/clients/shared-web/media/webviewSelectors.js +491 -0
- package/clients/viewer/README.md +5 -0
- package/clients/viewer/browser-host.js +847 -0
- package/clients/viewer/index.html +237 -0
- package/clients/viewer/viewer.css +433 -0
- package/logics_manager/assist.py +9 -142
- package/logics_manager/assist_handoff.py +132 -0
- package/logics_manager/assist_surface.py +38 -0
- package/logics_manager/cli.py +20 -0
- package/logics_manager/flow.py +126 -24
- package/logics_manager/flow_evidence.py +63 -0
- package/logics_manager/update_check.py +138 -0
- package/logics_manager/viewer.py +533 -0
- package/package.json +12 -6
- package/pyproject.toml +1 -1
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import mimetypes
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
import webbrowser
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from http import HTTPStatus
|
|
14
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
from urllib.parse import parse_qs, quote, unquote, urlencode, urlparse
|
|
18
|
+
|
|
19
|
+
from .audit import audit_payload
|
|
20
|
+
from .config import find_repo_root
|
|
21
|
+
from .lint import lint_payload
|
|
22
|
+
from .update_check import get_update_info
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ViewerDocFamily:
|
|
27
|
+
stage: str
|
|
28
|
+
directory: str
|
|
29
|
+
prefixes: tuple[str, ...]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
DOC_FAMILIES = (
|
|
33
|
+
ViewerDocFamily("request", "logics/request", ("req_",)),
|
|
34
|
+
ViewerDocFamily("backlog", "logics/backlog", ("item_",)),
|
|
35
|
+
ViewerDocFamily("task", "logics/tasks", ("task_",)),
|
|
36
|
+
ViewerDocFamily("product", "logics/product", ("prod_",)),
|
|
37
|
+
ViewerDocFamily("architecture", "logics/architecture", ("adr_",)),
|
|
38
|
+
ViewerDocFamily("spec", "logics/specs", ("spec_", "req_")),
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
STAGE_ORDER = {family.stage: index for index, family in enumerate(DOC_FAMILIES)}
|
|
42
|
+
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
43
|
+
VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
|
|
44
|
+
SHARED_MEDIA_ROOT = REPO_ROOT / "clients" / "shared-web" / "media"
|
|
45
|
+
DIST_VENDOR_ROOT = REPO_ROOT / "dist" / "vendor"
|
|
46
|
+
NODE_MERMAID_ROOT = REPO_ROOT / "node_modules" / "mermaid" / "dist"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _current_version() -> str:
|
|
50
|
+
try:
|
|
51
|
+
return (REPO_ROOT / "VERSION").read_text(encoding="utf-8").strip() or "0.0.0"
|
|
52
|
+
except OSError:
|
|
53
|
+
return "0.0.0"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _read_text(path: Path) -> str:
|
|
57
|
+
return path.read_text(encoding="utf-8")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_title(lines: list[str], fallback: str) -> str:
|
|
61
|
+
for line in lines:
|
|
62
|
+
if not line.startswith("## "):
|
|
63
|
+
continue
|
|
64
|
+
raw = line[3:].strip()
|
|
65
|
+
match = re.match(r"^\S+\s*-\s*(.+)$", raw)
|
|
66
|
+
return (match.group(1) if match else raw).strip()
|
|
67
|
+
return fallback
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_indicators(lines: list[str]) -> dict[str, str]:
|
|
71
|
+
indicators: dict[str, str] = {}
|
|
72
|
+
for line in lines:
|
|
73
|
+
if not line.startswith(">"):
|
|
74
|
+
continue
|
|
75
|
+
trimmed = re.sub(r"^>\s*", "", line).strip()
|
|
76
|
+
if ":" not in trimmed:
|
|
77
|
+
continue
|
|
78
|
+
key, value = trimmed.split(":", 1)
|
|
79
|
+
if key.strip() and value.strip():
|
|
80
|
+
indicators[key.strip()] = value.strip()
|
|
81
|
+
return indicators
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _extract_section_lines(content: str, section_title: str) -> list[str]:
|
|
85
|
+
expected = f"# {section_title}".lower()
|
|
86
|
+
collected: list[str] = []
|
|
87
|
+
in_section = False
|
|
88
|
+
for line in content.splitlines():
|
|
89
|
+
if line.strip().lower() == expected:
|
|
90
|
+
in_section = True
|
|
91
|
+
continue
|
|
92
|
+
if not in_section:
|
|
93
|
+
continue
|
|
94
|
+
if line.startswith("# "):
|
|
95
|
+
break
|
|
96
|
+
collected.append(line)
|
|
97
|
+
return collected
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _summary_entries(content: str, section_title: str, limit: int) -> list[str]:
|
|
101
|
+
entries: list[str] = []
|
|
102
|
+
for raw_line in _extract_section_lines(content, section_title):
|
|
103
|
+
line = raw_line.strip()
|
|
104
|
+
if not line or line.startswith("```") or line.startswith("%%") or re.fullmatch(r"-+", line):
|
|
105
|
+
continue
|
|
106
|
+
bullet = re.match(r"^[-*]\s+(.*)$", line)
|
|
107
|
+
value = bullet.group(1) if bullet else line
|
|
108
|
+
if not value.startswith("#"):
|
|
109
|
+
normalized = re.sub(r"\s+", " ", value.replace("> ", "")).strip()
|
|
110
|
+
if normalized and normalized.lower() not in {entry.lower() for entry in entries}:
|
|
111
|
+
entries.append(normalized)
|
|
112
|
+
if len(entries) >= limit:
|
|
113
|
+
break
|
|
114
|
+
return entries
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _build_summary_points(content: str, fallback_title: str) -> list[str]:
|
|
118
|
+
entries = [
|
|
119
|
+
*_summary_entries(content, "Needs", 2),
|
|
120
|
+
*_summary_entries(content, "Problem", 2),
|
|
121
|
+
*_summary_entries(content, "Context", 2),
|
|
122
|
+
*_summary_entries(content, "Scope", 2),
|
|
123
|
+
]
|
|
124
|
+
deduped: list[str] = []
|
|
125
|
+
for entry in entries:
|
|
126
|
+
if entry.lower() not in {existing.lower() for existing in deduped}:
|
|
127
|
+
deduped.append(entry)
|
|
128
|
+
return deduped[:4] or [fallback_title]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _collect_backticked_links(text: str) -> list[str]:
|
|
132
|
+
return [match.group(1) for match in re.finditer(r"`([^`]+)`", text) if match.group(1)]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _normalize_ref(value: str) -> str:
|
|
136
|
+
normalized = value.replace("\\", "/").lstrip("./").strip()
|
|
137
|
+
if "/" in normalized:
|
|
138
|
+
return normalized
|
|
139
|
+
bare_name = normalized[:-3] if normalized.endswith(".md") else normalized
|
|
140
|
+
for family in DOC_FAMILIES:
|
|
141
|
+
if bare_name.startswith(family.prefixes):
|
|
142
|
+
return f"{family.directory}/{bare_name}.md"
|
|
143
|
+
return normalized
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def normalize_viewer_focus_target(repo_root: Path, value: str) -> str:
|
|
147
|
+
raw = unquote(value).replace("\\", "/").strip()
|
|
148
|
+
if not raw:
|
|
149
|
+
raise ValueError("Focus target cannot be empty.")
|
|
150
|
+
if raw.startswith("~") or raw.startswith(("/", "\\")) or re.match(r"^[A-Za-z]:", raw):
|
|
151
|
+
raise ValueError("Focus target must be a workflow ref or repo-relative Logics path.")
|
|
152
|
+
parts = [part for part in raw.split("/") if part]
|
|
153
|
+
if any(part == ".." for part in parts):
|
|
154
|
+
raise ValueError("Focus target cannot contain path traversal.")
|
|
155
|
+
normalized = _normalize_ref(raw.lstrip("./")).lstrip("/")
|
|
156
|
+
if "/" not in raw and normalized == raw:
|
|
157
|
+
raise ValueError("Focus target must be a known workflow ref or repo-relative Logics path.")
|
|
158
|
+
if "/" in normalized:
|
|
159
|
+
absolute = (repo_root.resolve() / normalized).resolve()
|
|
160
|
+
root = repo_root.resolve()
|
|
161
|
+
if root != absolute and root not in absolute.parents:
|
|
162
|
+
raise ValueError("Focus target escapes repository root.")
|
|
163
|
+
allowed_prefixes = tuple(f"{family.directory}/" for family in DOC_FAMILIES)
|
|
164
|
+
if not normalized.startswith(allowed_prefixes) or not normalized.endswith(".md"):
|
|
165
|
+
raise ValueError("Focus target must point to a Logics Markdown document.")
|
|
166
|
+
return normalized
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def build_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str:
|
|
170
|
+
url = f"http://{host}:{port}"
|
|
171
|
+
query: dict[str, str] = {}
|
|
172
|
+
if focus:
|
|
173
|
+
query["focus"] = focus
|
|
174
|
+
if read:
|
|
175
|
+
query["read"] = "1"
|
|
176
|
+
if query:
|
|
177
|
+
url = f"{url}?{urlencode(query, quote_via=quote)}"
|
|
178
|
+
return url
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _section_links(content: str, section_title: str) -> list[str]:
|
|
182
|
+
links: list[str] = []
|
|
183
|
+
for line in _extract_section_lines(content, section_title):
|
|
184
|
+
if "(none yet)" in line:
|
|
185
|
+
continue
|
|
186
|
+
links.extend(_collect_backticked_links(line))
|
|
187
|
+
return sorted({_normalize_ref(link) for link in links})
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _indicator_links(lines: list[str], keys: set[str]) -> list[str]:
|
|
191
|
+
links: list[str] = []
|
|
192
|
+
for line in lines:
|
|
193
|
+
if not line.startswith(">"):
|
|
194
|
+
continue
|
|
195
|
+
trimmed = re.sub(r"^>\s*", "", line).strip()
|
|
196
|
+
if ":" not in trimmed:
|
|
197
|
+
continue
|
|
198
|
+
key, value = trimmed.split(":", 1)
|
|
199
|
+
if key.strip().lower() in keys:
|
|
200
|
+
links.extend(_collect_backticked_links(value))
|
|
201
|
+
return sorted({_normalize_ref(link) for link in links})
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _extract_references(content: str, lines: list[str]) -> list[dict[str, str]]:
|
|
205
|
+
references: list[dict[str, str]] = []
|
|
206
|
+
for label, pattern in (
|
|
207
|
+
("Promoted from", re.compile(r"Promoted from `([^`]+)`", re.IGNORECASE)),
|
|
208
|
+
("Derived from", re.compile(r"Derived from(?: [a-z][a-z ]+)? `([^`]+)`", re.IGNORECASE)),
|
|
209
|
+
):
|
|
210
|
+
for match in pattern.finditer(content):
|
|
211
|
+
references.append({"kind": "from", "label": label, "path": _normalize_ref(match.group(1))})
|
|
212
|
+
for link in _section_links(content, "Backlog"):
|
|
213
|
+
references.append({"kind": "backlog", "label": "Backlog", "path": link})
|
|
214
|
+
manual_links = {
|
|
215
|
+
*_section_links(content, "References"),
|
|
216
|
+
*_indicator_links(lines, {"related request", "related backlog", "related task", "related architecture"}),
|
|
217
|
+
}
|
|
218
|
+
for link in sorted(manual_links):
|
|
219
|
+
references.append({"kind": "manual", "label": "Reference", "path": link})
|
|
220
|
+
return references
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _infer_stage(rel_path: str, doc_id: str) -> str:
|
|
224
|
+
normalized = rel_path.replace("\\", "/").lower()
|
|
225
|
+
for family in DOC_FAMILIES:
|
|
226
|
+
if normalized.startswith(f"{family.directory}/") or doc_id.startswith(family.prefixes):
|
|
227
|
+
return family.stage
|
|
228
|
+
return "request"
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _to_usage(rel_path: str, items_by_rel_path: dict[str, dict[str, Any]]) -> dict[str, str]:
|
|
232
|
+
normalized = _normalize_ref(rel_path)
|
|
233
|
+
matched = items_by_rel_path.get(normalized)
|
|
234
|
+
if matched:
|
|
235
|
+
return {
|
|
236
|
+
"id": str(matched["id"]),
|
|
237
|
+
"title": str(matched["title"]),
|
|
238
|
+
"stage": str(matched["stage"]),
|
|
239
|
+
"relPath": str(matched["relPath"]),
|
|
240
|
+
}
|
|
241
|
+
doc_id = Path(normalized).stem
|
|
242
|
+
return {
|
|
243
|
+
"id": doc_id or normalized,
|
|
244
|
+
"title": doc_id or normalized,
|
|
245
|
+
"stage": _infer_stage(normalized, doc_id),
|
|
246
|
+
"relPath": normalized,
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def collect_viewer_items(repo_root: Path) -> list[dict[str, Any]]:
|
|
251
|
+
repo_root = repo_root.resolve()
|
|
252
|
+
items: list[dict[str, Any]] = []
|
|
253
|
+
promoted_sources: set[str] = set()
|
|
254
|
+
usage_map: dict[str, list[dict[str, str]]] = {}
|
|
255
|
+
manual_used_by: dict[str, list[str]] = {}
|
|
256
|
+
|
|
257
|
+
for family in DOC_FAMILIES:
|
|
258
|
+
directory = repo_root / family.directory
|
|
259
|
+
if not directory.is_dir():
|
|
260
|
+
continue
|
|
261
|
+
for path in sorted(directory.glob("*.md")):
|
|
262
|
+
if not path.name.startswith(family.prefixes):
|
|
263
|
+
continue
|
|
264
|
+
content = _read_text(path)
|
|
265
|
+
lines = content.splitlines()
|
|
266
|
+
rel_path = path.relative_to(repo_root).as_posix()
|
|
267
|
+
title = _parse_title(lines, path.stem)
|
|
268
|
+
references = _extract_references(content, lines)
|
|
269
|
+
manual_used_by[rel_path] = _section_links(content, "Used by")
|
|
270
|
+
for ref in references:
|
|
271
|
+
if ref["kind"] == "from":
|
|
272
|
+
promoted_sources.add(_normalize_ref(ref["path"]))
|
|
273
|
+
stat = path.stat()
|
|
274
|
+
items.append(
|
|
275
|
+
{
|
|
276
|
+
"id": path.stem,
|
|
277
|
+
"title": title,
|
|
278
|
+
"stage": family.stage,
|
|
279
|
+
"path": str(path),
|
|
280
|
+
"relPath": rel_path,
|
|
281
|
+
"filename": path.name,
|
|
282
|
+
"updatedAt": stat.st_mtime_ns,
|
|
283
|
+
"indicators": _parse_indicators(lines),
|
|
284
|
+
"summaryPoints": _build_summary_points(content, title),
|
|
285
|
+
"acceptanceCriteria": _summary_entries(content, "Acceptance criteria", 6),
|
|
286
|
+
"lineCount": len(lines),
|
|
287
|
+
"charCount": len(content),
|
|
288
|
+
"isPromoted": False,
|
|
289
|
+
"references": references,
|
|
290
|
+
"usedBy": [],
|
|
291
|
+
}
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
items_by_rel_path = {str(item["relPath"]): item for item in items}
|
|
295
|
+
for item in items:
|
|
296
|
+
rel_path = str(item["relPath"])
|
|
297
|
+
item["isPromoted"] = rel_path in promoted_sources
|
|
298
|
+
for ref in item["references"]:
|
|
299
|
+
target = _normalize_ref(str(ref["path"]))
|
|
300
|
+
if target in items_by_rel_path:
|
|
301
|
+
usage_map.setdefault(target, []).append(
|
|
302
|
+
{
|
|
303
|
+
"id": str(item["id"]),
|
|
304
|
+
"title": str(item["title"]),
|
|
305
|
+
"stage": str(item["stage"]),
|
|
306
|
+
"relPath": rel_path,
|
|
307
|
+
}
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
for item in items:
|
|
311
|
+
rel_path = str(item["relPath"])
|
|
312
|
+
usages = usage_map.get(rel_path, [])
|
|
313
|
+
for link in manual_used_by.get(rel_path, []):
|
|
314
|
+
usage = _to_usage(link, items_by_rel_path)
|
|
315
|
+
if not any(existing["relPath"] == usage["relPath"] for existing in usages):
|
|
316
|
+
usages.append(usage)
|
|
317
|
+
item["usedBy"] = sorted(usages, key=lambda usage: (STAGE_ORDER.get(usage["stage"], 99), usage["id"]))
|
|
318
|
+
|
|
319
|
+
items.sort(key=lambda item: (STAGE_ORDER.get(str(item["stage"]), 99), str(item["id"])))
|
|
320
|
+
for item in items:
|
|
321
|
+
item["updatedAt"] = datetime.fromtimestamp(Path(str(item["path"])).stat().st_mtime).isoformat()
|
|
322
|
+
return items
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def viewer_data_payload(repo_root: Path, selected_id: str | None = None) -> dict[str, Any]:
|
|
326
|
+
return {
|
|
327
|
+
"root": str(repo_root.resolve()),
|
|
328
|
+
"items": collect_viewer_items(repo_root),
|
|
329
|
+
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
330
|
+
"selectedId": selected_id,
|
|
331
|
+
"changedPaths": [],
|
|
332
|
+
"canResetProjectRoot": False,
|
|
333
|
+
"canBootstrapLogics": False,
|
|
334
|
+
"bootstrapLogicsTitle": "Local viewer is read-only. Use the CLI to bootstrap Logics.",
|
|
335
|
+
"canLaunchCodex": False,
|
|
336
|
+
"canLaunchClaude": False,
|
|
337
|
+
"canRepairLogicsKit": False,
|
|
338
|
+
"canPublishRelease": False,
|
|
339
|
+
"shouldRecommendCheckEnvironment": False,
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def read_doc_payload(repo_root: Path, rel_path: str) -> dict[str, Any]:
|
|
344
|
+
normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
|
|
345
|
+
return {
|
|
346
|
+
"path": normalized,
|
|
347
|
+
"content": _read_text(absolute),
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _resolve_repo_doc_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
|
|
352
|
+
normalized = unquote(rel_path).replace("\\", "/").lstrip("/")
|
|
353
|
+
absolute = (repo_root / normalized).resolve()
|
|
354
|
+
root = repo_root.resolve()
|
|
355
|
+
if root != absolute and root not in absolute.parents:
|
|
356
|
+
raise ValueError("Document path escapes repository root.")
|
|
357
|
+
if not absolute.is_file():
|
|
358
|
+
raise FileNotFoundError(normalized)
|
|
359
|
+
return normalized, absolute
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def edit_doc_payload(repo_root: Path, rel_path: str, *, launcher: Any | None = None) -> dict[str, str]:
|
|
363
|
+
normalized, absolute = _resolve_repo_doc_path(repo_root, rel_path)
|
|
364
|
+
command = _system_editor_command(absolute)
|
|
365
|
+
runner = launcher or subprocess.Popen
|
|
366
|
+
runner(command)
|
|
367
|
+
return {
|
|
368
|
+
"path": normalized,
|
|
369
|
+
"command": command[0],
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _system_editor_command(path: Path) -> list[str]:
|
|
374
|
+
if sys.platform == "darwin":
|
|
375
|
+
return ["open", str(path)]
|
|
376
|
+
if os.name == "nt":
|
|
377
|
+
return ["cmd", "/c", "start", "", str(path)]
|
|
378
|
+
return ["xdg-open", str(path)]
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _json_bytes(payload: Any) -> bytes:
|
|
382
|
+
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
class LogicsViewerServer(ThreadingHTTPServer):
|
|
386
|
+
def __init__(self, server_address: tuple[str, int], repo_root: Path):
|
|
387
|
+
self.repo_root = repo_root.resolve()
|
|
388
|
+
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
392
|
+
server: LogicsViewerServer
|
|
393
|
+
|
|
394
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
395
|
+
return
|
|
396
|
+
|
|
397
|
+
def _send_bytes(self, content: bytes, *, status: int = 200, content_type: str = "application/octet-stream") -> None:
|
|
398
|
+
self.send_response(status)
|
|
399
|
+
self.send_header("Content-Type", content_type)
|
|
400
|
+
self.send_header("Cache-Control", "no-store")
|
|
401
|
+
self.end_headers()
|
|
402
|
+
try:
|
|
403
|
+
self.wfile.write(content)
|
|
404
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
405
|
+
return
|
|
406
|
+
|
|
407
|
+
def _send_json(self, payload: Any, *, status: int = 200) -> None:
|
|
408
|
+
self._send_bytes(_json_bytes(payload), status=status, content_type="application/json; charset=utf-8")
|
|
409
|
+
|
|
410
|
+
def _send_error_json(self, status: HTTPStatus, message: str) -> None:
|
|
411
|
+
self._send_json({"ok": False, "error": message}, status=status.value)
|
|
412
|
+
|
|
413
|
+
def _serve_file(self, path: Path) -> None:
|
|
414
|
+
if not path.is_file():
|
|
415
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
416
|
+
return
|
|
417
|
+
content_type = mimetypes.guess_type(path.name)[0] or "application/octet-stream"
|
|
418
|
+
if content_type.startswith("text/") or path.suffix in {".js", ".css", ".html"}:
|
|
419
|
+
content_type = f"{content_type}; charset=utf-8"
|
|
420
|
+
self._send_bytes(path.read_bytes(), content_type=content_type)
|
|
421
|
+
|
|
422
|
+
def do_GET(self) -> None:
|
|
423
|
+
parsed = urlparse(self.path)
|
|
424
|
+
route = parsed.path
|
|
425
|
+
if route == "/":
|
|
426
|
+
self._serve_file(VIEWER_ROOT / "index.html")
|
|
427
|
+
return
|
|
428
|
+
if route == "/browser-host.js":
|
|
429
|
+
self._serve_file(VIEWER_ROOT / "browser-host.js")
|
|
430
|
+
return
|
|
431
|
+
if route == "/viewer.css":
|
|
432
|
+
self._serve_file(VIEWER_ROOT / "viewer.css")
|
|
433
|
+
return
|
|
434
|
+
if route == "/vendor/mermaid.min.js":
|
|
435
|
+
vendor_path = DIST_VENDOR_ROOT / "mermaid.min.js"
|
|
436
|
+
if not vendor_path.is_file():
|
|
437
|
+
vendor_path = NODE_MERMAID_ROOT / "mermaid.min.js"
|
|
438
|
+
self._serve_file(vendor_path)
|
|
439
|
+
return
|
|
440
|
+
if route.startswith("/media/"):
|
|
441
|
+
media_path = (SHARED_MEDIA_ROOT / route.removeprefix("/media/")).resolve()
|
|
442
|
+
if SHARED_MEDIA_ROOT.resolve() != media_path and SHARED_MEDIA_ROOT.resolve() not in media_path.parents:
|
|
443
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
444
|
+
return
|
|
445
|
+
self._serve_file(media_path)
|
|
446
|
+
return
|
|
447
|
+
if route == "/api/items":
|
|
448
|
+
self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
|
|
449
|
+
return
|
|
450
|
+
if route == "/api/doc":
|
|
451
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
452
|
+
try:
|
|
453
|
+
self._send_json({"ok": True, "document": read_doc_payload(self.server.repo_root, rel_path)})
|
|
454
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
455
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
456
|
+
return
|
|
457
|
+
if route == "/api/lint":
|
|
458
|
+
self._send_json({"ok": True, "payload": lint_payload(self.server.repo_root)})
|
|
459
|
+
return
|
|
460
|
+
if route == "/api/audit":
|
|
461
|
+
self._send_json({"ok": True, "payload": audit_payload(self.server.repo_root)})
|
|
462
|
+
return
|
|
463
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
464
|
+
|
|
465
|
+
def do_POST(self) -> None:
|
|
466
|
+
parsed = urlparse(self.path)
|
|
467
|
+
if parsed.path == "/api/refresh":
|
|
468
|
+
self._send_json({"ok": True, "payload": viewer_data_payload(self.server.repo_root)})
|
|
469
|
+
return
|
|
470
|
+
if parsed.path == "/api/edit":
|
|
471
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
472
|
+
try:
|
|
473
|
+
self._send_json({"ok": True, "document": edit_doc_payload(self.server.repo_root, rel_path)})
|
|
474
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
475
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
476
|
+
except OSError as exc:
|
|
477
|
+
self._send_error_json(HTTPStatus.INTERNAL_SERVER_ERROR, str(exc))
|
|
478
|
+
return
|
|
479
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def create_viewer_server(repo_root: Path, host: str = "127.0.0.1", port: int = 8765) -> LogicsViewerServer:
|
|
483
|
+
return LogicsViewerServer((host, port), repo_root)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def render_start_status(url: str, repo_root: Path, *, focus: str | None = None) -> str:
|
|
487
|
+
lines = [
|
|
488
|
+
"Logics viewer running:",
|
|
489
|
+
url,
|
|
490
|
+
"",
|
|
491
|
+
f"Repo: {repo_root.name}",
|
|
492
|
+
"Mode: read-only",
|
|
493
|
+
"Bind: localhost",
|
|
494
|
+
]
|
|
495
|
+
if focus:
|
|
496
|
+
lines.append(f"Focus: {focus}")
|
|
497
|
+
return "\n".join(lines)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
501
|
+
parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
|
|
502
|
+
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
|
|
503
|
+
parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
|
|
504
|
+
parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
|
|
505
|
+
parser.add_argument("--read", action="store_true", help="Open the focused item in the read preview. Requires --focus.")
|
|
506
|
+
parser.add_argument("--open", action="store_true", help="Open the viewer in the default browser.")
|
|
507
|
+
parser.add_argument("--no-open", action="store_true", help="Do not open the browser. This is the default.")
|
|
508
|
+
return parser
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def main(argv: list[str]) -> int:
|
|
512
|
+
args = build_parser().parse_args(argv)
|
|
513
|
+
repo_root = find_repo_root(Path.cwd())
|
|
514
|
+
if args.read and not args.focus:
|
|
515
|
+
raise SystemExit("--read requires --focus.")
|
|
516
|
+
try:
|
|
517
|
+
focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
|
|
518
|
+
except ValueError as exc:
|
|
519
|
+
raise SystemExit(str(exc)) from exc
|
|
520
|
+
server = create_viewer_server(repo_root, host=args.host, port=args.port)
|
|
521
|
+
host, port = server.server_address[:2]
|
|
522
|
+
url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
523
|
+
print(render_start_status(url, repo_root, focus=focus), flush=True)
|
|
524
|
+
if args.open and not args.no_open:
|
|
525
|
+
webbrowser.open(url)
|
|
526
|
+
|
|
527
|
+
try:
|
|
528
|
+
server.serve_forever()
|
|
529
|
+
except KeyboardInterrupt:
|
|
530
|
+
return 0
|
|
531
|
+
finally:
|
|
532
|
+
server.server_close()
|
|
533
|
+
return 0
|
package/package.json
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
"name": "@grifhinz/logics-manager",
|
|
3
3
|
"displayName": "Logics Orchestrator",
|
|
4
4
|
"description": "Visual orchestration for Logics workflows inside VS Code.",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.3.0",
|
|
6
6
|
"publisher": "cdx-logics",
|
|
7
|
-
"icon": "media/icon.png",
|
|
7
|
+
"icon": "clients/shared-web/media/icon.png",
|
|
8
8
|
"repository": {
|
|
9
9
|
"type": "git",
|
|
10
10
|
"url": "git+https://github.com/AlexAgo83/logics-manager.git"
|
|
@@ -29,6 +29,11 @@
|
|
|
29
29
|
"pyproject.toml",
|
|
30
30
|
"scripts/logics-manager.py",
|
|
31
31
|
"scripts/npm/",
|
|
32
|
+
"clients/shared-web/media/",
|
|
33
|
+
"clients/viewer/index.html",
|
|
34
|
+
"clients/viewer/browser-host.js",
|
|
35
|
+
"clients/viewer/viewer.css",
|
|
36
|
+
"dist/vendor/mermaid.min.js",
|
|
32
37
|
"logics_manager/*.py",
|
|
33
38
|
"logics_manager/**/*.py"
|
|
34
39
|
],
|
|
@@ -38,7 +43,7 @@
|
|
|
38
43
|
{
|
|
39
44
|
"id": "logics",
|
|
40
45
|
"title": "Logics",
|
|
41
|
-
"icon": "media/logics.svg"
|
|
46
|
+
"icon": "clients/shared-web/media/logics.svg"
|
|
42
47
|
}
|
|
43
48
|
]
|
|
44
49
|
},
|
|
@@ -47,7 +52,7 @@
|
|
|
47
52
|
{
|
|
48
53
|
"id": "logics.orchestrator",
|
|
49
54
|
"name": "Orchestrator",
|
|
50
|
-
"icon": "media/logics.svg",
|
|
55
|
+
"icon": "clients/shared-web/media/logics.svg",
|
|
51
56
|
"type": "webview"
|
|
52
57
|
}
|
|
53
58
|
]
|
|
@@ -127,7 +132,7 @@
|
|
|
127
132
|
"test:watch": "vitest",
|
|
128
133
|
"lint": "npm run lint:ts && npm run lint:es",
|
|
129
134
|
"lint:ts": "tsc -p ./ --noEmit",
|
|
130
|
-
"lint:es": "eslint src/**/*.ts",
|
|
135
|
+
"lint:es": "eslint clients/vscode/src/**/*.ts",
|
|
131
136
|
"lint:logics": "node scripts/run-python.mjs -m logics_manager lint",
|
|
132
137
|
"audit:logics": "node scripts/run-python.mjs -m logics_manager audit && node scripts/run-python.mjs -m logics_manager lint",
|
|
133
138
|
"audit:logics:strict": "node scripts/run-python.mjs -m logics_manager audit --governance-profile strict && node scripts/run-python.mjs -m logics_manager lint --require-status",
|
|
@@ -136,9 +141,10 @@
|
|
|
136
141
|
"ci:fast": "npm run compile && npm run lint && npm run test:coverage && npm run test:smoke && npm run lint:logics && npm run package:ci",
|
|
137
142
|
"ci:check": "node scripts/ci-check.mjs",
|
|
138
143
|
"dev": "npm run compile && code --extensionDevelopmentPath=.",
|
|
139
|
-
"debug:webview": "node debug
|
|
144
|
+
"debug:webview": "node clients/viewer/debug-webview/server.mjs",
|
|
140
145
|
"package:ci": "node scripts/build/package-ci.mjs",
|
|
141
146
|
"package": "npm run compile && node scripts/build/package-release.mjs",
|
|
147
|
+
"package:npm": "node scripts/build/package-npm.mjs",
|
|
142
148
|
"install:vsix": "npm run package && node scripts/build/install-vsix.mjs",
|
|
143
149
|
"publish:npm": "node scripts/release/publish-npm.mjs",
|
|
144
150
|
"release:changelog:resolve": "node scripts/release/resolve-release-changelog.mjs --validate-current",
|