@grifhinz/logics-manager 2.8.0 → 2.9.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 +22 -7
- package/VERSION +1 -1
- package/clients/shared-web/media/vendor/xterm/PROVENANCE.md +31 -0
- package/clients/shared-web/media/vendor/xterm/xterm-addon-fit.js +2 -0
- package/clients/shared-web/media/vendor/xterm/xterm-addon-web-links.js +2 -0
- package/clients/shared-web/media/vendor/xterm/xterm.css +209 -0
- package/clients/shared-web/media/vendor/xterm/xterm.js +2 -0
- package/clients/viewer/browser-host.js +1302 -33
- package/clients/viewer/index.html +15 -0
- package/clients/viewer/viewer.css +938 -70
- package/logics_manager/cli.py +17 -17
- package/logics_manager/viewer.py +1370 -30
- package/package.json +2 -1
- package/pyproject.toml +1 -1
package/logics_manager/viewer.py
CHANGED
|
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
4
|
import hashlib
|
|
5
|
+
import hmac
|
|
5
6
|
import json
|
|
7
|
+
import secrets
|
|
6
8
|
import mimetypes
|
|
7
9
|
import os
|
|
8
10
|
import re
|
|
@@ -10,6 +12,7 @@ import shutil
|
|
|
10
12
|
import socket
|
|
11
13
|
import subprocess
|
|
12
14
|
import sys
|
|
15
|
+
import tomllib
|
|
13
16
|
import webbrowser
|
|
14
17
|
from dataclasses import dataclass
|
|
15
18
|
from datetime import datetime
|
|
@@ -49,6 +52,8 @@ CDX_MISSION_STRENGTHS = {
|
|
|
49
52
|
"deep": {"id": "deep", "label": "Deep", "timeout": 300, "reasoningEffort": "high", "power": "high"},
|
|
50
53
|
"max": {"id": "max", "label": "Max", "timeout": 600, "reasoningEffort": "high", "power": "high"},
|
|
51
54
|
}
|
|
55
|
+
CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS = 90
|
|
56
|
+
CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS = 600
|
|
52
57
|
CDX_MISSION_CATALOG = {
|
|
53
58
|
"full-audit": {
|
|
54
59
|
"id": "full-audit",
|
|
@@ -142,6 +147,26 @@ GIT_FILE_PREVIEW_MAX_BYTES = 30000
|
|
|
142
147
|
GIT_FILE_PREVIEW_MAX_CHARS = 20000
|
|
143
148
|
FILE_PREVIEW_MAX_BYTES = 300000
|
|
144
149
|
FILE_PREVIEW_MAX_CHARS = 200000
|
|
150
|
+
WORKSPACE_TREE_MAX_ENTRIES = 250
|
|
151
|
+
WORKSPACE_PREVIEW_MAX_BYTES = 30000
|
|
152
|
+
WORKSPACE_PREVIEW_MAX_CHARS = 20000
|
|
153
|
+
WORKSPACE_IGNORED_DIRS = {
|
|
154
|
+
".git",
|
|
155
|
+
".hg",
|
|
156
|
+
".svn",
|
|
157
|
+
"__pycache__",
|
|
158
|
+
".pytest_cache",
|
|
159
|
+
".mypy_cache",
|
|
160
|
+
".ruff_cache",
|
|
161
|
+
"node_modules",
|
|
162
|
+
"dist",
|
|
163
|
+
"build",
|
|
164
|
+
"coverage",
|
|
165
|
+
".next",
|
|
166
|
+
".turbo",
|
|
167
|
+
".venv",
|
|
168
|
+
"venv",
|
|
169
|
+
}
|
|
145
170
|
REPO_ROOT = Path(__file__).resolve().parents[1]
|
|
146
171
|
PACKAGE_VIEWER_ASSETS_ROOT = Path(__file__).resolve().parent / "viewer_assets"
|
|
147
172
|
VIEWER_ROOT = REPO_ROOT / "clients" / "viewer"
|
|
@@ -442,6 +467,7 @@ def viewer_data_payload(
|
|
|
442
467
|
selected_id: str | None = None,
|
|
443
468
|
*,
|
|
444
469
|
auto_refresh_interval_seconds: int = 15,
|
|
470
|
+
auto_refresh_interval_forced: bool = False,
|
|
445
471
|
projects: list[dict[str, Any]] | None = None,
|
|
446
472
|
) -> dict[str, Any]:
|
|
447
473
|
capabilities = viewer_project_capabilities(repo_root)
|
|
@@ -457,6 +483,7 @@ def viewer_data_payload(
|
|
|
457
483
|
"capabilities": capabilities,
|
|
458
484
|
"projects": projects if projects is not None else viewer_project_registry(repo_root),
|
|
459
485
|
"autoRefreshIntervalSeconds": auto_refresh_interval_seconds,
|
|
486
|
+
"autoRefreshIntervalForced": auto_refresh_interval_forced,
|
|
460
487
|
"items": collect_viewer_items(repo_root),
|
|
461
488
|
"updateInfo": get_update_info(_current_version()).to_payload(),
|
|
462
489
|
"selectedId": selected_id,
|
|
@@ -611,9 +638,31 @@ def viewer_project_capabilities(
|
|
|
611
638
|
else:
|
|
612
639
|
cdx = _viewer_capability("missing", available=False, message="CDX executable is not available.")
|
|
613
640
|
cdx_runs = _viewer_capability("missing", available=False, message="CDX is required before assistant runs can be tracked.")
|
|
641
|
+
workspace = _viewer_capability(
|
|
642
|
+
"ready" if repo_root.is_dir() else "missing",
|
|
643
|
+
available=repo_root.is_dir(),
|
|
644
|
+
message="Workspace root can be inspected." if repo_root.is_dir() else "Workspace root is unavailable.",
|
|
645
|
+
detail={"root": str(repo_root.resolve())} if repo_root.is_dir() else {},
|
|
646
|
+
)
|
|
647
|
+
workshop_available = repo_root.is_dir()
|
|
648
|
+
terminals_available = workshop_available and workshop_terminals_available()
|
|
649
|
+
if terminals_available:
|
|
650
|
+
workshop_message = "Workshop command runner and PTY terminals are available."
|
|
651
|
+
elif workshop_available:
|
|
652
|
+
workshop_message = "Workshop command runner is available; PTY terminals require a Unix host with stdlib pty support."
|
|
653
|
+
else:
|
|
654
|
+
workshop_message = "Workshop is not available without a workspace root."
|
|
655
|
+
workshop = _viewer_capability(
|
|
656
|
+
"ready" if workshop_available else "missing",
|
|
657
|
+
available=workshop_available,
|
|
658
|
+
message=workshop_message,
|
|
659
|
+
detail={"terminalsAvailable": terminals_available, "commandsAvailable": workshop_available},
|
|
660
|
+
)
|
|
614
661
|
|
|
615
662
|
return {
|
|
616
663
|
"logics": logics,
|
|
664
|
+
"workspace": workspace,
|
|
665
|
+
"workshop": workshop,
|
|
617
666
|
"git": git,
|
|
618
667
|
"ci": ci,
|
|
619
668
|
"cdx": cdx,
|
|
@@ -1156,6 +1205,278 @@ def git_file_preview_payload(
|
|
|
1156
1205
|
}
|
|
1157
1206
|
|
|
1158
1207
|
|
|
1208
|
+
def _normalize_workspace_path(rel_path: str) -> str:
|
|
1209
|
+
normalized = unquote(rel_path or "").replace("\\", "/").strip()
|
|
1210
|
+
normalized = normalized.lstrip("/")
|
|
1211
|
+
if normalized in {"", "."}:
|
|
1212
|
+
return ""
|
|
1213
|
+
if normalized.startswith("~") or re.match(r"^[A-Za-z]:", normalized):
|
|
1214
|
+
raise ValueError("Unsafe workspace path.")
|
|
1215
|
+
parts = [part for part in normalized.split("/") if part not in {"", "."}]
|
|
1216
|
+
if any(part == ".." for part in parts):
|
|
1217
|
+
raise ValueError("Workspace path escapes root.")
|
|
1218
|
+
return "/".join(parts)
|
|
1219
|
+
|
|
1220
|
+
|
|
1221
|
+
def _resolve_workspace_path(repo_root: Path, rel_path: str) -> tuple[str, Path]:
|
|
1222
|
+
normalized = _normalize_workspace_path(rel_path)
|
|
1223
|
+
root = repo_root.resolve()
|
|
1224
|
+
target = (root / normalized).resolve()
|
|
1225
|
+
try:
|
|
1226
|
+
target.relative_to(root)
|
|
1227
|
+
except ValueError as exc:
|
|
1228
|
+
raise ValueError("Workspace path escapes root.") from exc
|
|
1229
|
+
return normalized, target
|
|
1230
|
+
|
|
1231
|
+
|
|
1232
|
+
def _workspace_entry_payload(root: Path, path: Path) -> dict[str, Any]:
|
|
1233
|
+
try:
|
|
1234
|
+
stat = path.stat()
|
|
1235
|
+
except OSError:
|
|
1236
|
+
stat = None
|
|
1237
|
+
rel_path = path.relative_to(root).as_posix()
|
|
1238
|
+
is_dir = path.is_dir()
|
|
1239
|
+
ignored = is_dir and path.name in WORKSPACE_IGNORED_DIRS
|
|
1240
|
+
return {
|
|
1241
|
+
"name": path.name or root.name,
|
|
1242
|
+
"path": rel_path,
|
|
1243
|
+
"kind": "directory" if is_dir else "file",
|
|
1244
|
+
"size": stat.st_size if stat else 0,
|
|
1245
|
+
"ignored": ignored,
|
|
1246
|
+
"childrenAvailable": is_dir and not ignored,
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def workspace_tree_payload(
|
|
1251
|
+
repo_root: Path,
|
|
1252
|
+
rel_path: str = "",
|
|
1253
|
+
*,
|
|
1254
|
+
max_entries: int = WORKSPACE_TREE_MAX_ENTRIES,
|
|
1255
|
+
) -> dict[str, Any]:
|
|
1256
|
+
normalized, target = _resolve_workspace_path(repo_root, rel_path)
|
|
1257
|
+
root = repo_root.resolve()
|
|
1258
|
+
if not target.exists():
|
|
1259
|
+
return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
|
|
1260
|
+
if not target.is_dir():
|
|
1261
|
+
return {"state": "not-directory", "path": normalized, "message": "Workspace path is not a directory."}
|
|
1262
|
+
entries = []
|
|
1263
|
+
truncated = False
|
|
1264
|
+
try:
|
|
1265
|
+
children = sorted(target.iterdir(), key=lambda path: (not path.is_dir(), path.name.lower()))
|
|
1266
|
+
except OSError as exc:
|
|
1267
|
+
return {"state": "error", "path": normalized, "message": f"Unable to list workspace path: {exc}"}
|
|
1268
|
+
for child in children:
|
|
1269
|
+
if len(entries) >= max_entries:
|
|
1270
|
+
truncated = True
|
|
1271
|
+
break
|
|
1272
|
+
entries.append(_workspace_entry_payload(root, child))
|
|
1273
|
+
return {
|
|
1274
|
+
"state": "ok",
|
|
1275
|
+
"root": str(root),
|
|
1276
|
+
"path": normalized,
|
|
1277
|
+
"entries": entries,
|
|
1278
|
+
"truncated": truncated,
|
|
1279
|
+
"ignoredDirectories": sorted(WORKSPACE_IGNORED_DIRS),
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def workspace_preview_payload(
|
|
1284
|
+
repo_root: Path,
|
|
1285
|
+
rel_path: str,
|
|
1286
|
+
*,
|
|
1287
|
+
max_bytes: int = WORKSPACE_PREVIEW_MAX_BYTES,
|
|
1288
|
+
max_chars: int = WORKSPACE_PREVIEW_MAX_CHARS,
|
|
1289
|
+
) -> dict[str, Any]:
|
|
1290
|
+
normalized, target = _resolve_workspace_path(repo_root, rel_path)
|
|
1291
|
+
if not target.exists():
|
|
1292
|
+
return {"state": "missing", "path": normalized, "message": "Workspace path does not exist."}
|
|
1293
|
+
if target.is_dir():
|
|
1294
|
+
try:
|
|
1295
|
+
count = sum(1 for _ in target.iterdir())
|
|
1296
|
+
except OSError:
|
|
1297
|
+
count = 0
|
|
1298
|
+
return {
|
|
1299
|
+
"state": "directory",
|
|
1300
|
+
"path": normalized,
|
|
1301
|
+
"name": target.name or repo_root.resolve().name,
|
|
1302
|
+
"kind": "directory",
|
|
1303
|
+
"message": f"{count} item(s)",
|
|
1304
|
+
"childrenAvailable": target.name not in WORKSPACE_IGNORED_DIRS,
|
|
1305
|
+
}
|
|
1306
|
+
if not target.is_file():
|
|
1307
|
+
return {"state": "unsupported", "path": normalized, "message": "Workspace object cannot be previewed."}
|
|
1308
|
+
try:
|
|
1309
|
+
size = target.stat().st_size
|
|
1310
|
+
except OSError as exc:
|
|
1311
|
+
return {"state": "error", "path": normalized, "message": f"Unable to inspect file: {exc}"}
|
|
1312
|
+
if size > max_bytes:
|
|
1313
|
+
return {
|
|
1314
|
+
"state": "oversized",
|
|
1315
|
+
"path": normalized,
|
|
1316
|
+
"name": target.name,
|
|
1317
|
+
"size": size,
|
|
1318
|
+
"message": f"File preview is limited to {max_bytes} bytes; this file is {size} bytes.",
|
|
1319
|
+
}
|
|
1320
|
+
try:
|
|
1321
|
+
data = target.read_bytes()
|
|
1322
|
+
except OSError as exc:
|
|
1323
|
+
return {"state": "error", "path": normalized, "message": f"Unable to read file preview: {exc}"}
|
|
1324
|
+
content_type = mimetypes.guess_type(target.name)[0] or ""
|
|
1325
|
+
if content_type.startswith("image/"):
|
|
1326
|
+
return {
|
|
1327
|
+
"state": "image",
|
|
1328
|
+
"path": normalized,
|
|
1329
|
+
"name": target.name,
|
|
1330
|
+
"size": size,
|
|
1331
|
+
"contentType": content_type,
|
|
1332
|
+
"message": "Image preview is available from the workspace file endpoint.",
|
|
1333
|
+
}
|
|
1334
|
+
if b"\x00" in data:
|
|
1335
|
+
return {
|
|
1336
|
+
"state": "unsupported",
|
|
1337
|
+
"path": normalized,
|
|
1338
|
+
"name": target.name,
|
|
1339
|
+
"size": size,
|
|
1340
|
+
"message": "Binary or unsupported file content cannot be previewed.",
|
|
1341
|
+
}
|
|
1342
|
+
try:
|
|
1343
|
+
content = data.decode("utf-8")
|
|
1344
|
+
except UnicodeDecodeError:
|
|
1345
|
+
return {
|
|
1346
|
+
"state": "unsupported",
|
|
1347
|
+
"path": normalized,
|
|
1348
|
+
"name": target.name,
|
|
1349
|
+
"size": size,
|
|
1350
|
+
"message": "Binary or unsupported file encoding cannot be previewed.",
|
|
1351
|
+
}
|
|
1352
|
+
content = content.replace("\r\n", "\n").replace("\r", "\n")
|
|
1353
|
+
truncated = len(content) > max_chars
|
|
1354
|
+
if truncated:
|
|
1355
|
+
content = content[:max_chars]
|
|
1356
|
+
return {
|
|
1357
|
+
"state": "ok",
|
|
1358
|
+
"path": normalized,
|
|
1359
|
+
"name": target.name,
|
|
1360
|
+
"kind": "file",
|
|
1361
|
+
"size": size,
|
|
1362
|
+
"contentType": content_type or "text/plain",
|
|
1363
|
+
"content": content,
|
|
1364
|
+
"truncated": truncated,
|
|
1365
|
+
"logicsType": _logics_doc_type(normalized),
|
|
1366
|
+
"message": "",
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
WORKSHOP_COMMAND_MAX = 200
|
|
1371
|
+
|
|
1372
|
+
|
|
1373
|
+
def _workshop_command_id(group: str, name: str) -> str:
|
|
1374
|
+
safe = re.sub(r"[^a-z0-9._-]+", "-", f"{group}:{name}".lower()).strip("-") or "command"
|
|
1375
|
+
return safe[:80]
|
|
1376
|
+
|
|
1377
|
+
|
|
1378
|
+
def _discover_package_json_scripts(repo_root: Path) -> list[dict[str, Any]]:
|
|
1379
|
+
target = repo_root / "package.json"
|
|
1380
|
+
if not target.is_file():
|
|
1381
|
+
return []
|
|
1382
|
+
try:
|
|
1383
|
+
with target.open("rb") as handle:
|
|
1384
|
+
payload = json.load(handle)
|
|
1385
|
+
except (OSError, ValueError):
|
|
1386
|
+
return []
|
|
1387
|
+
scripts = payload.get("scripts") if isinstance(payload, dict) else None
|
|
1388
|
+
if not isinstance(scripts, dict):
|
|
1389
|
+
return []
|
|
1390
|
+
entries: list[dict[str, Any]] = []
|
|
1391
|
+
for name, command in scripts.items():
|
|
1392
|
+
if not isinstance(name, str) or not isinstance(command, str):
|
|
1393
|
+
continue
|
|
1394
|
+
entries.append(
|
|
1395
|
+
{
|
|
1396
|
+
"id": _workshop_command_id("npm", name),
|
|
1397
|
+
"source": "package.json",
|
|
1398
|
+
"group": "npm scripts",
|
|
1399
|
+
"name": name,
|
|
1400
|
+
"command": command,
|
|
1401
|
+
"runner": ["npm", "run", name],
|
|
1402
|
+
}
|
|
1403
|
+
)
|
|
1404
|
+
if len(entries) >= WORKSHOP_COMMAND_MAX:
|
|
1405
|
+
break
|
|
1406
|
+
return entries
|
|
1407
|
+
|
|
1408
|
+
|
|
1409
|
+
def _discover_pyproject_scripts(repo_root: Path) -> list[dict[str, Any]]:
|
|
1410
|
+
target = repo_root / "pyproject.toml"
|
|
1411
|
+
if not target.is_file():
|
|
1412
|
+
return []
|
|
1413
|
+
try:
|
|
1414
|
+
with target.open("rb") as handle:
|
|
1415
|
+
payload = tomllib.load(handle)
|
|
1416
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
1417
|
+
return []
|
|
1418
|
+
entries: list[dict[str, Any]] = []
|
|
1419
|
+
project_scripts = (payload.get("project") or {}).get("scripts")
|
|
1420
|
+
if isinstance(project_scripts, dict):
|
|
1421
|
+
for name, target_ref in project_scripts.items():
|
|
1422
|
+
if not isinstance(name, str) or not isinstance(target_ref, str):
|
|
1423
|
+
continue
|
|
1424
|
+
entries.append(
|
|
1425
|
+
{
|
|
1426
|
+
"id": _workshop_command_id("pyproject", name),
|
|
1427
|
+
"source": "pyproject.toml [project.scripts]",
|
|
1428
|
+
"group": "Project scripts",
|
|
1429
|
+
"name": name,
|
|
1430
|
+
"command": target_ref,
|
|
1431
|
+
"runner": [name],
|
|
1432
|
+
}
|
|
1433
|
+
)
|
|
1434
|
+
if len(entries) >= WORKSHOP_COMMAND_MAX:
|
|
1435
|
+
return entries
|
|
1436
|
+
poetry_scripts = (
|
|
1437
|
+
((payload.get("tool") or {}).get("poetry") or {}).get("scripts")
|
|
1438
|
+
if isinstance(payload.get("tool"), dict)
|
|
1439
|
+
else None
|
|
1440
|
+
)
|
|
1441
|
+
if isinstance(poetry_scripts, dict):
|
|
1442
|
+
for name, target_ref in poetry_scripts.items():
|
|
1443
|
+
if not isinstance(name, str) or not isinstance(target_ref, str):
|
|
1444
|
+
continue
|
|
1445
|
+
entries.append(
|
|
1446
|
+
{
|
|
1447
|
+
"id": _workshop_command_id("poetry", name),
|
|
1448
|
+
"source": "pyproject.toml [tool.poetry.scripts]",
|
|
1449
|
+
"group": "Poetry scripts",
|
|
1450
|
+
"name": name,
|
|
1451
|
+
"command": target_ref,
|
|
1452
|
+
"runner": ["poetry", "run", name],
|
|
1453
|
+
}
|
|
1454
|
+
)
|
|
1455
|
+
if len(entries) >= WORKSHOP_COMMAND_MAX:
|
|
1456
|
+
break
|
|
1457
|
+
return entries
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
def workshop_commands_payload(repo_root: Path) -> dict[str, Any]:
|
|
1461
|
+
if not repo_root.is_dir():
|
|
1462
|
+
return {"state": "unavailable", "commands": [], "message": "Workspace root is unavailable."}
|
|
1463
|
+
commands: list[dict[str, Any]] = []
|
|
1464
|
+
commands.extend(_discover_package_json_scripts(repo_root))
|
|
1465
|
+
commands.extend(_discover_pyproject_scripts(repo_root))
|
|
1466
|
+
seen: set[str] = set()
|
|
1467
|
+
deduped: list[dict[str, Any]] = []
|
|
1468
|
+
for entry in commands:
|
|
1469
|
+
if entry["id"] in seen:
|
|
1470
|
+
continue
|
|
1471
|
+
seen.add(entry["id"])
|
|
1472
|
+
deduped.append(entry)
|
|
1473
|
+
return {
|
|
1474
|
+
"state": "ok" if deduped else "empty",
|
|
1475
|
+
"commands": deduped,
|
|
1476
|
+
"message": "" if deduped else "No package.json or pyproject.toml entry points were found in the workspace root.",
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
|
|
1159
1480
|
def _current_git_ci_context(repo_root: Path, *, runner: Any | None = None) -> dict[str, str]:
|
|
1160
1481
|
context = {"branch": "", "headSha": "", "subject": "", "author": ""}
|
|
1161
1482
|
commands = {
|
|
@@ -1195,28 +1516,21 @@ def _is_active_ci_status(run: dict[str, Any]) -> bool:
|
|
|
1195
1516
|
|
|
1196
1517
|
|
|
1197
1518
|
def _select_github_actions_run(runs: list[dict[str, Any]], head_sha: str) -> tuple[dict[str, Any], str]:
|
|
1198
|
-
|
|
1519
|
+
ci_runs = [run for run in runs if str(run.get("name") or "").strip().lower() == "ci"]
|
|
1520
|
+
candidate_runs = ci_runs or runs
|
|
1521
|
+
head_runs = [run for run in candidate_runs if head_sha and str(run.get("head_sha") or "") == head_sha]
|
|
1199
1522
|
active_head_run = next((run for run in head_runs if _is_active_ci_status(run)), None)
|
|
1200
1523
|
if active_head_run is not None:
|
|
1201
1524
|
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
1525
|
if head_runs:
|
|
1526
|
+
head_state = _ci_badge_state(str(head_runs[0].get("status") or ""), str(head_runs[0].get("conclusion") or ""))
|
|
1527
|
+
if head_state in {"failing", "cancelled", "unknown"}:
|
|
1528
|
+
return head_runs[0], f"head-{head_state}"
|
|
1212
1529
|
return head_runs[0], "head"
|
|
1213
|
-
active_branch_run = next((run for run in
|
|
1530
|
+
active_branch_run = next((run for run in candidate_runs if _is_active_ci_status(run)), None)
|
|
1214
1531
|
if active_branch_run is not None:
|
|
1215
1532
|
return active_branch_run, "branch-active"
|
|
1216
|
-
|
|
1217
|
-
if failing_branch_run is not None:
|
|
1218
|
-
return failing_branch_run, "branch-failing"
|
|
1219
|
-
return runs[0], "branch-latest"
|
|
1533
|
+
return candidate_runs[0], "branch-latest"
|
|
1220
1534
|
|
|
1221
1535
|
|
|
1222
1536
|
def _parse_github_actions_run(run: dict[str, Any], *, match_source: str) -> dict[str, Any]:
|
|
@@ -1605,6 +1919,13 @@ def _cdx_mission_prompt(
|
|
|
1605
1919
|
raise ValueError("Unknown CDX mission.")
|
|
1606
1920
|
|
|
1607
1921
|
|
|
1922
|
+
def _cdx_mission_timeout(strength: dict[str, Any], *, allow_file_writes: bool = False, commit_at_end: bool = False) -> int:
|
|
1923
|
+
timeout = int(strength.get("timeout") or 180)
|
|
1924
|
+
if allow_file_writes or commit_at_end:
|
|
1925
|
+
return max(timeout, CDX_WRITABLE_MISSION_MIN_TIMEOUT_SECONDS)
|
|
1926
|
+
return timeout
|
|
1927
|
+
|
|
1928
|
+
|
|
1608
1929
|
def _cdx_mission_command(
|
|
1609
1930
|
repo_root: Path,
|
|
1610
1931
|
mission_id: str,
|
|
@@ -1627,7 +1948,7 @@ def _cdx_mission_command(
|
|
|
1627
1948
|
direct_fixes=mission_inputs.get("directFixes") == "true",
|
|
1628
1949
|
commit_at_end=commit_at_end,
|
|
1629
1950
|
)
|
|
1630
|
-
timeout =
|
|
1951
|
+
timeout = _cdx_mission_timeout(strength, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end)
|
|
1631
1952
|
reasoning_effort = str(strength.get("reasoningEffort") or "medium")
|
|
1632
1953
|
power = str(strength.get("power") or "medium")
|
|
1633
1954
|
permission = "workspace-write" if allow_file_writes else "read-only"
|
|
@@ -1868,6 +2189,7 @@ def cdx_mission_plan_payload(
|
|
|
1868
2189
|
"requestedCommitAtEnd": requested_commit_at_end,
|
|
1869
2190
|
"supportsFileWrites": supports_file_writes,
|
|
1870
2191
|
"permission": permission,
|
|
2192
|
+
"timeoutSeconds": _cdx_mission_timeout(strength_def, allow_file_writes=allow_file_writes, commit_at_end=commit_at_end),
|
|
1871
2193
|
"command": ["cdx", *command],
|
|
1872
2194
|
"arguments": command,
|
|
1873
2195
|
"warnings": warnings,
|
|
@@ -1895,9 +2217,10 @@ def cdx_mission_run_payload(
|
|
|
1895
2217
|
if plan_payload.get("state") != "ok":
|
|
1896
2218
|
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
2219
|
plan = plan_payload["plan"]
|
|
1898
|
-
timeout = int(plan["strength"].get("timeout") or 180)
|
|
2220
|
+
timeout = int(plan.get("timeoutSeconds") or plan["strength"].get("timeout") or 180)
|
|
2221
|
+
process_timeout = timeout + CDX_MISSION_PARENT_TIMEOUT_GRACE_SECONDS
|
|
1899
2222
|
try:
|
|
1900
|
-
result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=
|
|
2223
|
+
result = _run_cdx_mission(repo_root, list(plan["arguments"]), timeout=process_timeout, runner=cdx_runner)
|
|
1901
2224
|
except subprocess.TimeoutExpired:
|
|
1902
2225
|
return {"state": "timeout", "message": "CDX mission timed out.", "plan": plan, "run": None}
|
|
1903
2226
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
@@ -2116,6 +2439,556 @@ def _json_bytes(payload: Any) -> bytes:
|
|
|
2116
2439
|
return json.dumps(payload, indent=2, sort_keys=True).encode("utf-8")
|
|
2117
2440
|
|
|
2118
2441
|
|
|
2442
|
+
VIEWER_MUTATING_ROUTES = frozenset(
|
|
2443
|
+
{
|
|
2444
|
+
"/api/edit",
|
|
2445
|
+
"/api/open-file",
|
|
2446
|
+
"/api/open-repo-folder",
|
|
2447
|
+
"/api/bootstrap-logics",
|
|
2448
|
+
"/api/switch-project",
|
|
2449
|
+
"/api/cdx-report-request",
|
|
2450
|
+
"/api/cdx-mission-run",
|
|
2451
|
+
"/api/cdx-mission-apply-plan",
|
|
2452
|
+
"/api/workshop-command-start",
|
|
2453
|
+
"/api/workshop-command-stop",
|
|
2454
|
+
"/api/workshop-terminal-start",
|
|
2455
|
+
"/api/workshop-terminal-stop",
|
|
2456
|
+
"/api/workshop-terminal-input",
|
|
2457
|
+
"/api/workshop-terminal-resize",
|
|
2458
|
+
}
|
|
2459
|
+
)
|
|
2460
|
+
|
|
2461
|
+
|
|
2462
|
+
_WORKSHOP_SESSION_BUFFER_MAX = 4000
|
|
2463
|
+
_WORKSHOP_SESSION_TTL_SECONDS = 600
|
|
2464
|
+
|
|
2465
|
+
|
|
2466
|
+
class WorkshopCommandSession:
|
|
2467
|
+
"""Sandboxed subprocess for a Workshop command run.
|
|
2468
|
+
|
|
2469
|
+
Captures merged stdout+stderr into a ring buffer that SSE consumers can
|
|
2470
|
+
tail incrementally via a monotonically increasing sequence number. Stop
|
|
2471
|
+
delivers SIGTERM (Unix) or CTRL_BREAK_EVENT (Windows) to the process
|
|
2472
|
+
group and falls back to SIGKILL if the process refuses to exit.
|
|
2473
|
+
"""
|
|
2474
|
+
|
|
2475
|
+
def __init__(self, session_id: str, command_id: str, runner: list[str], cwd: Path):
|
|
2476
|
+
import collections
|
|
2477
|
+
import threading
|
|
2478
|
+
self.session_id = session_id
|
|
2479
|
+
self.command_id = command_id
|
|
2480
|
+
self.runner = list(runner)
|
|
2481
|
+
self.cwd = cwd
|
|
2482
|
+
self.started_at = ""
|
|
2483
|
+
self.finished_at = ""
|
|
2484
|
+
self.exit_code: int | None = None
|
|
2485
|
+
self.state = "starting"
|
|
2486
|
+
self.error: str = ""
|
|
2487
|
+
self._buffer: collections.deque[tuple[int, str]] = collections.deque(maxlen=_WORKSHOP_SESSION_BUFFER_MAX)
|
|
2488
|
+
self._seq = 0
|
|
2489
|
+
self._lock = threading.Lock()
|
|
2490
|
+
self._proc: subprocess.Popen[bytes] | None = None
|
|
2491
|
+
self._reader: threading.Thread | None = None
|
|
2492
|
+
self._waiter: threading.Thread | None = None
|
|
2493
|
+
self._created_at = self._now()
|
|
2494
|
+
self._last_activity = self._created_at
|
|
2495
|
+
|
|
2496
|
+
@staticmethod
|
|
2497
|
+
def _now() -> float:
|
|
2498
|
+
import time
|
|
2499
|
+
return time.monotonic()
|
|
2500
|
+
|
|
2501
|
+
@staticmethod
|
|
2502
|
+
def _iso_now() -> str:
|
|
2503
|
+
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
2504
|
+
|
|
2505
|
+
def append_line(self, channel: str, text: str) -> None:
|
|
2506
|
+
with self._lock:
|
|
2507
|
+
self._seq += 1
|
|
2508
|
+
self._buffer.append((self._seq, f"{channel}\t{text}"))
|
|
2509
|
+
self._last_activity = self._now()
|
|
2510
|
+
|
|
2511
|
+
def tail(self, since_seq: int) -> tuple[int, list[tuple[int, str]]]:
|
|
2512
|
+
with self._lock:
|
|
2513
|
+
snapshot = [(seq, line) for (seq, line) in self._buffer if seq > since_seq]
|
|
2514
|
+
return self._seq, snapshot
|
|
2515
|
+
|
|
2516
|
+
def status_payload(self) -> dict[str, Any]:
|
|
2517
|
+
with self._lock:
|
|
2518
|
+
return {
|
|
2519
|
+
"id": self.session_id,
|
|
2520
|
+
"commandId": self.command_id,
|
|
2521
|
+
"runner": list(self.runner),
|
|
2522
|
+
"state": self.state,
|
|
2523
|
+
"exitCode": self.exit_code,
|
|
2524
|
+
"startedAt": self.started_at,
|
|
2525
|
+
"finishedAt": self.finished_at,
|
|
2526
|
+
"lastSeq": self._seq,
|
|
2527
|
+
"error": self.error,
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
def is_expired(self, ttl_seconds: float = _WORKSHOP_SESSION_TTL_SECONDS) -> bool:
|
|
2531
|
+
if self.state in {"running", "starting"}:
|
|
2532
|
+
return False
|
|
2533
|
+
return (self._now() - self._last_activity) > ttl_seconds
|
|
2534
|
+
|
|
2535
|
+
def start(self) -> None:
|
|
2536
|
+
import threading
|
|
2537
|
+
creation_flags = 0
|
|
2538
|
+
popen_kwargs: dict[str, Any] = {
|
|
2539
|
+
"cwd": str(self.cwd),
|
|
2540
|
+
"stdout": subprocess.PIPE,
|
|
2541
|
+
"stderr": subprocess.STDOUT,
|
|
2542
|
+
"stdin": subprocess.DEVNULL,
|
|
2543
|
+
}
|
|
2544
|
+
if sys.platform == "win32":
|
|
2545
|
+
creation_flags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
|
|
2546
|
+
popen_kwargs["creationflags"] = creation_flags
|
|
2547
|
+
else:
|
|
2548
|
+
popen_kwargs["start_new_session"] = True
|
|
2549
|
+
try:
|
|
2550
|
+
self._proc = subprocess.Popen(self.runner, **popen_kwargs)
|
|
2551
|
+
except (OSError, ValueError) as exc:
|
|
2552
|
+
self.state = "error"
|
|
2553
|
+
self.error = f"Unable to start command: {exc}"
|
|
2554
|
+
self.finished_at = self._iso_now()
|
|
2555
|
+
return
|
|
2556
|
+
self.started_at = self._iso_now()
|
|
2557
|
+
self.state = "running"
|
|
2558
|
+
self._reader = threading.Thread(target=self._read_loop, name=f"workshop-reader-{self.session_id}", daemon=True)
|
|
2559
|
+
self._reader.start()
|
|
2560
|
+
self._waiter = threading.Thread(target=self._wait_loop, name=f"workshop-waiter-{self.session_id}", daemon=True)
|
|
2561
|
+
self._waiter.start()
|
|
2562
|
+
|
|
2563
|
+
def _read_loop(self) -> None:
|
|
2564
|
+
proc = self._proc
|
|
2565
|
+
if proc is None or proc.stdout is None:
|
|
2566
|
+
return
|
|
2567
|
+
try:
|
|
2568
|
+
for raw in iter(proc.stdout.readline, b""):
|
|
2569
|
+
try:
|
|
2570
|
+
text = raw.decode("utf-8", errors="replace").rstrip("\r\n")
|
|
2571
|
+
except Exception:
|
|
2572
|
+
continue
|
|
2573
|
+
self.append_line("stdout", text)
|
|
2574
|
+
except (OSError, ValueError):
|
|
2575
|
+
pass
|
|
2576
|
+
|
|
2577
|
+
def _wait_loop(self) -> None:
|
|
2578
|
+
proc = self._proc
|
|
2579
|
+
if proc is None:
|
|
2580
|
+
return
|
|
2581
|
+
try:
|
|
2582
|
+
code = proc.wait()
|
|
2583
|
+
except Exception as exc:
|
|
2584
|
+
self.error = f"Wait failed: {exc}"
|
|
2585
|
+
code = -1
|
|
2586
|
+
with self._lock:
|
|
2587
|
+
self.exit_code = code
|
|
2588
|
+
self.finished_at = self._iso_now()
|
|
2589
|
+
self.state = "finished" if code == 0 else ("stopped" if code in (-15, 143, -9, 137) else "failed")
|
|
2590
|
+
self._last_activity = self._now()
|
|
2591
|
+
|
|
2592
|
+
def stop(self, *, timeout: float = 5.0) -> None:
|
|
2593
|
+
proc = self._proc
|
|
2594
|
+
if proc is None or proc.poll() is not None:
|
|
2595
|
+
return
|
|
2596
|
+
try:
|
|
2597
|
+
if sys.platform == "win32":
|
|
2598
|
+
sig = getattr(__import__("signal"), "CTRL_BREAK_EVENT", None)
|
|
2599
|
+
if sig is not None:
|
|
2600
|
+
proc.send_signal(sig)
|
|
2601
|
+
else:
|
|
2602
|
+
proc.terminate()
|
|
2603
|
+
else:
|
|
2604
|
+
import os as _os
|
|
2605
|
+
import signal as _signal
|
|
2606
|
+
try:
|
|
2607
|
+
_os.killpg(proc.pid, _signal.SIGTERM)
|
|
2608
|
+
except (OSError, ProcessLookupError):
|
|
2609
|
+
proc.terminate()
|
|
2610
|
+
except Exception:
|
|
2611
|
+
proc.terminate()
|
|
2612
|
+
try:
|
|
2613
|
+
proc.wait(timeout=timeout)
|
|
2614
|
+
except subprocess.TimeoutExpired:
|
|
2615
|
+
try:
|
|
2616
|
+
if sys.platform == "win32":
|
|
2617
|
+
proc.kill()
|
|
2618
|
+
else:
|
|
2619
|
+
import os as _os
|
|
2620
|
+
import signal as _signal
|
|
2621
|
+
_os.killpg(proc.pid, _signal.SIGKILL)
|
|
2622
|
+
except Exception:
|
|
2623
|
+
proc.kill()
|
|
2624
|
+
|
|
2625
|
+
|
|
2626
|
+
class WorkshopSessionRegistry:
|
|
2627
|
+
def __init__(self) -> None:
|
|
2628
|
+
import threading
|
|
2629
|
+
self._sessions: dict[str, WorkshopCommandSession] = {}
|
|
2630
|
+
self._lock = threading.Lock()
|
|
2631
|
+
self._counter = 0
|
|
2632
|
+
|
|
2633
|
+
def create(self, command_entry: dict[str, Any], repo_root: Path) -> WorkshopCommandSession:
|
|
2634
|
+
runner = command_entry.get("runner")
|
|
2635
|
+
if not isinstance(runner, list) or not runner or not all(isinstance(part, str) and part for part in runner):
|
|
2636
|
+
raise ValueError("Command entry is missing a valid runner.")
|
|
2637
|
+
if not repo_root.is_dir():
|
|
2638
|
+
raise ValueError("Workspace root is unavailable.")
|
|
2639
|
+
with self._lock:
|
|
2640
|
+
self._counter += 1
|
|
2641
|
+
session_id = f"ws-{self._counter:06d}"
|
|
2642
|
+
session = WorkshopCommandSession(
|
|
2643
|
+
session_id=session_id,
|
|
2644
|
+
command_id=str(command_entry.get("id") or ""),
|
|
2645
|
+
runner=runner,
|
|
2646
|
+
cwd=repo_root,
|
|
2647
|
+
)
|
|
2648
|
+
with self._lock:
|
|
2649
|
+
self._prune_locked()
|
|
2650
|
+
self._sessions[session_id] = session
|
|
2651
|
+
session.start()
|
|
2652
|
+
return session
|
|
2653
|
+
|
|
2654
|
+
def get(self, session_id: str) -> WorkshopCommandSession | None:
|
|
2655
|
+
with self._lock:
|
|
2656
|
+
return self._sessions.get(session_id)
|
|
2657
|
+
|
|
2658
|
+
def list(self) -> list[dict[str, Any]]:
|
|
2659
|
+
with self._lock:
|
|
2660
|
+
self._prune_locked()
|
|
2661
|
+
return [session.status_payload() for session in self._sessions.values()]
|
|
2662
|
+
|
|
2663
|
+
def _prune_locked(self) -> None:
|
|
2664
|
+
for sid in list(self._sessions.keys()):
|
|
2665
|
+
if self._sessions[sid].is_expired():
|
|
2666
|
+
del self._sessions[sid]
|
|
2667
|
+
|
|
2668
|
+
def shutdown(self) -> None:
|
|
2669
|
+
with self._lock:
|
|
2670
|
+
sessions = list(self._sessions.values())
|
|
2671
|
+
for session in sessions:
|
|
2672
|
+
session.stop(timeout=1.0)
|
|
2673
|
+
|
|
2674
|
+
|
|
2675
|
+
_WORKSHOP_TERMINAL_BUFFER_MAX = 8000
|
|
2676
|
+
_WORKSHOP_TERMINAL_TTL_SECONDS = 1800
|
|
2677
|
+
_WORKSHOP_TERMINAL_IDLE_KILL_SECONDS = 60.0
|
|
2678
|
+
|
|
2679
|
+
|
|
2680
|
+
def workshop_terminals_available() -> bool:
|
|
2681
|
+
"""True when the host can spawn PTY sessions through the stdlib backend."""
|
|
2682
|
+
if sys.platform == "win32":
|
|
2683
|
+
return False
|
|
2684
|
+
try:
|
|
2685
|
+
import pty # noqa: F401
|
|
2686
|
+
import termios # noqa: F401
|
|
2687
|
+
except ImportError:
|
|
2688
|
+
return False
|
|
2689
|
+
return True
|
|
2690
|
+
|
|
2691
|
+
|
|
2692
|
+
def _default_workshop_shell() -> list[str]:
|
|
2693
|
+
candidate = os.environ.get("SHELL") or ""
|
|
2694
|
+
if candidate and os.access(candidate, os.X_OK):
|
|
2695
|
+
return [candidate, "-i"]
|
|
2696
|
+
for fallback in ("/bin/zsh", "/bin/bash", "/bin/sh"):
|
|
2697
|
+
if os.access(fallback, os.X_OK):
|
|
2698
|
+
return [fallback, "-i"]
|
|
2699
|
+
return ["/bin/sh"]
|
|
2700
|
+
|
|
2701
|
+
|
|
2702
|
+
class WorkshopTerminalSession:
|
|
2703
|
+
"""Interactive PTY-backed terminal session using stdlib `pty`.
|
|
2704
|
+
|
|
2705
|
+
Reads from the master fd in a daemon thread, buffers output bytes
|
|
2706
|
+
(decoded utf-8 with replace) into a ring; writes from the client land
|
|
2707
|
+
on the master fd directly. Resize uses TIOCSWINSZ ioctl. Stop sends
|
|
2708
|
+
SIGTERM to the session leader and falls back to SIGKILL after a grace
|
|
2709
|
+
window. Unix-only: Windows callers must check
|
|
2710
|
+
workshop_terminals_available() before instantiating.
|
|
2711
|
+
"""
|
|
2712
|
+
|
|
2713
|
+
def __init__(self, session_id: str, command: list[str], cwd: Path, *, label: str = ""):
|
|
2714
|
+
import collections
|
|
2715
|
+
import threading
|
|
2716
|
+
self.session_id = session_id
|
|
2717
|
+
self.command = list(command)
|
|
2718
|
+
self.cwd = cwd
|
|
2719
|
+
self.label = label or (command[0] if command else "shell")
|
|
2720
|
+
self.started_at = ""
|
|
2721
|
+
self.finished_at = ""
|
|
2722
|
+
self.exit_code: int | None = None
|
|
2723
|
+
self.state = "starting"
|
|
2724
|
+
self.error: str = ""
|
|
2725
|
+
self._buffer: collections.deque[tuple[int, str]] = collections.deque(maxlen=_WORKSHOP_TERMINAL_BUFFER_MAX)
|
|
2726
|
+
self._seq = 0
|
|
2727
|
+
self._lock = threading.Lock()
|
|
2728
|
+
self._master_fd: int | None = None
|
|
2729
|
+
self._pid: int | None = None
|
|
2730
|
+
self._reader: threading.Thread | None = None
|
|
2731
|
+
self._reaper: threading.Thread | None = None
|
|
2732
|
+
self._created_at = self._now()
|
|
2733
|
+
self._last_activity = self._created_at
|
|
2734
|
+
self._listeners = 0
|
|
2735
|
+
self._idle_timer: threading.Timer | None = None
|
|
2736
|
+
self._idle_kill_seconds = _WORKSHOP_TERMINAL_IDLE_KILL_SECONDS
|
|
2737
|
+
|
|
2738
|
+
@staticmethod
|
|
2739
|
+
def _now() -> float:
|
|
2740
|
+
import time
|
|
2741
|
+
return time.monotonic()
|
|
2742
|
+
|
|
2743
|
+
@staticmethod
|
|
2744
|
+
def _iso_now() -> str:
|
|
2745
|
+
return datetime.utcnow().isoformat(timespec="seconds") + "Z"
|
|
2746
|
+
|
|
2747
|
+
def _append(self, text: str) -> None:
|
|
2748
|
+
with self._lock:
|
|
2749
|
+
self._seq += 1
|
|
2750
|
+
self._buffer.append((self._seq, text))
|
|
2751
|
+
self._last_activity = self._now()
|
|
2752
|
+
|
|
2753
|
+
def tail(self, since_seq: int) -> tuple[int, list[tuple[int, str]]]:
|
|
2754
|
+
with self._lock:
|
|
2755
|
+
snapshot = [(seq, chunk) for (seq, chunk) in self._buffer if seq > since_seq]
|
|
2756
|
+
return self._seq, snapshot
|
|
2757
|
+
|
|
2758
|
+
def status_payload(self) -> dict[str, Any]:
|
|
2759
|
+
with self._lock:
|
|
2760
|
+
return {
|
|
2761
|
+
"id": self.session_id,
|
|
2762
|
+
"label": self.label,
|
|
2763
|
+
"command": list(self.command),
|
|
2764
|
+
"state": self.state,
|
|
2765
|
+
"exitCode": self.exit_code,
|
|
2766
|
+
"startedAt": self.started_at,
|
|
2767
|
+
"finishedAt": self.finished_at,
|
|
2768
|
+
"lastSeq": self._seq,
|
|
2769
|
+
"error": self.error,
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
def is_expired(self, ttl_seconds: float = _WORKSHOP_TERMINAL_TTL_SECONDS) -> bool:
|
|
2773
|
+
if self.state in {"running", "starting"}:
|
|
2774
|
+
return False
|
|
2775
|
+
return (self._now() - self._last_activity) > ttl_seconds
|
|
2776
|
+
|
|
2777
|
+
def attach_listener(self) -> None:
|
|
2778
|
+
"""Register a live SSE consumer for this session."""
|
|
2779
|
+
with self._lock:
|
|
2780
|
+
self._listeners += 1
|
|
2781
|
+
if self._idle_timer is not None:
|
|
2782
|
+
self._idle_timer.cancel()
|
|
2783
|
+
self._idle_timer = None
|
|
2784
|
+
|
|
2785
|
+
def detach_listener(self) -> None:
|
|
2786
|
+
"""Release an SSE consumer; arm the idle-kill timer if none remain."""
|
|
2787
|
+
import threading
|
|
2788
|
+
arm = False
|
|
2789
|
+
with self._lock:
|
|
2790
|
+
self._listeners = max(0, self._listeners - 1)
|
|
2791
|
+
if self._listeners == 0 and self.state in {"running", "starting"}:
|
|
2792
|
+
if self._idle_timer is not None:
|
|
2793
|
+
self._idle_timer.cancel()
|
|
2794
|
+
self._idle_timer = threading.Timer(self._idle_kill_seconds, self._on_idle_timeout)
|
|
2795
|
+
self._idle_timer.daemon = True
|
|
2796
|
+
arm = True
|
|
2797
|
+
if arm and self._idle_timer is not None:
|
|
2798
|
+
self._idle_timer.start()
|
|
2799
|
+
|
|
2800
|
+
def _on_idle_timeout(self) -> None:
|
|
2801
|
+
with self._lock:
|
|
2802
|
+
still_idle = self._listeners == 0 and self.state in {"running", "starting"}
|
|
2803
|
+
self._idle_timer = None
|
|
2804
|
+
if not still_idle:
|
|
2805
|
+
return
|
|
2806
|
+
# Best-effort: SIGTERM the session group, falling back to SIGKILL.
|
|
2807
|
+
self.stop(timeout=3.0)
|
|
2808
|
+
|
|
2809
|
+
def start(self) -> None:
|
|
2810
|
+
import threading
|
|
2811
|
+
if not workshop_terminals_available():
|
|
2812
|
+
self.state = "error"
|
|
2813
|
+
self.error = "PTY backend is not available on this host."
|
|
2814
|
+
self.finished_at = self._iso_now()
|
|
2815
|
+
return
|
|
2816
|
+
import pty
|
|
2817
|
+
try:
|
|
2818
|
+
pid, master_fd = pty.fork()
|
|
2819
|
+
except (OSError, RuntimeError) as exc:
|
|
2820
|
+
self.state = "error"
|
|
2821
|
+
self.error = f"Unable to fork PTY: {exc}"
|
|
2822
|
+
self.finished_at = self._iso_now()
|
|
2823
|
+
return
|
|
2824
|
+
if pid == 0:
|
|
2825
|
+
try:
|
|
2826
|
+
os.chdir(str(self.cwd))
|
|
2827
|
+
except OSError:
|
|
2828
|
+
pass
|
|
2829
|
+
env = os.environ.copy()
|
|
2830
|
+
env.setdefault("TERM", "xterm-256color")
|
|
2831
|
+
env.setdefault("COLORTERM", "truecolor")
|
|
2832
|
+
try:
|
|
2833
|
+
os.execvpe(self.command[0], self.command, env)
|
|
2834
|
+
except Exception as exc: # noqa: BLE001
|
|
2835
|
+
sys.stderr.write(f"Unable to exec {self.command[0]}: {exc}\n")
|
|
2836
|
+
os._exit(127)
|
|
2837
|
+
self._pid = pid
|
|
2838
|
+
self._master_fd = master_fd
|
|
2839
|
+
self.started_at = self._iso_now()
|
|
2840
|
+
self.state = "running"
|
|
2841
|
+
self._reader = threading.Thread(target=self._read_loop, name=f"workshop-pty-reader-{self.session_id}", daemon=True)
|
|
2842
|
+
self._reader.start()
|
|
2843
|
+
self._reaper = threading.Thread(target=self._reap_loop, name=f"workshop-pty-reaper-{self.session_id}", daemon=True)
|
|
2844
|
+
self._reaper.start()
|
|
2845
|
+
|
|
2846
|
+
def write(self, data: str) -> None:
|
|
2847
|
+
if not data or self._master_fd is None:
|
|
2848
|
+
return
|
|
2849
|
+
try:
|
|
2850
|
+
os.write(self._master_fd, data.encode("utf-8"))
|
|
2851
|
+
except OSError as exc:
|
|
2852
|
+
self.error = f"Write failed: {exc}"
|
|
2853
|
+
|
|
2854
|
+
def resize(self, rows: int, cols: int) -> None:
|
|
2855
|
+
if self._master_fd is None or rows <= 0 or cols <= 0:
|
|
2856
|
+
return
|
|
2857
|
+
try:
|
|
2858
|
+
import fcntl
|
|
2859
|
+
import struct
|
|
2860
|
+
import termios
|
|
2861
|
+
fcntl.ioctl(self._master_fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
|
|
2862
|
+
except (OSError, ImportError):
|
|
2863
|
+
return
|
|
2864
|
+
|
|
2865
|
+
def _read_loop(self) -> None:
|
|
2866
|
+
fd = self._master_fd
|
|
2867
|
+
if fd is None:
|
|
2868
|
+
return
|
|
2869
|
+
try:
|
|
2870
|
+
while True:
|
|
2871
|
+
try:
|
|
2872
|
+
chunk = os.read(fd, 4096)
|
|
2873
|
+
except OSError:
|
|
2874
|
+
break
|
|
2875
|
+
if not chunk:
|
|
2876
|
+
break
|
|
2877
|
+
try:
|
|
2878
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
2879
|
+
except Exception: # noqa: BLE001
|
|
2880
|
+
continue
|
|
2881
|
+
self._append(text)
|
|
2882
|
+
finally:
|
|
2883
|
+
try:
|
|
2884
|
+
os.close(fd)
|
|
2885
|
+
except OSError:
|
|
2886
|
+
pass
|
|
2887
|
+
|
|
2888
|
+
def _reap_loop(self) -> None:
|
|
2889
|
+
pid = self._pid
|
|
2890
|
+
if pid is None:
|
|
2891
|
+
return
|
|
2892
|
+
try:
|
|
2893
|
+
_, status = os.waitpid(pid, 0)
|
|
2894
|
+
except OSError as exc:
|
|
2895
|
+
self.error = f"waitpid failed: {exc}"
|
|
2896
|
+
status = -1
|
|
2897
|
+
if isinstance(status, int):
|
|
2898
|
+
if os.WIFEXITED(status):
|
|
2899
|
+
code = os.WEXITSTATUS(status)
|
|
2900
|
+
elif os.WIFSIGNALED(status):
|
|
2901
|
+
code = -os.WTERMSIG(status)
|
|
2902
|
+
else:
|
|
2903
|
+
code = -1
|
|
2904
|
+
else:
|
|
2905
|
+
code = -1
|
|
2906
|
+
with self._lock:
|
|
2907
|
+
self.exit_code = code
|
|
2908
|
+
self.finished_at = self._iso_now()
|
|
2909
|
+
self.state = "finished" if code == 0 else ("stopped" if code in (-15, -9) else "failed")
|
|
2910
|
+
self._last_activity = self._now()
|
|
2911
|
+
if self._idle_timer is not None:
|
|
2912
|
+
self._idle_timer.cancel()
|
|
2913
|
+
self._idle_timer = None
|
|
2914
|
+
|
|
2915
|
+
def stop(self, *, timeout: float = 3.0) -> None:
|
|
2916
|
+
pid = self._pid
|
|
2917
|
+
if pid is None:
|
|
2918
|
+
return
|
|
2919
|
+
import signal as _signal
|
|
2920
|
+
import time as _time
|
|
2921
|
+
try:
|
|
2922
|
+
os.killpg(os.getpgid(pid), _signal.SIGTERM)
|
|
2923
|
+
except (OSError, ProcessLookupError):
|
|
2924
|
+
try:
|
|
2925
|
+
os.kill(pid, _signal.SIGTERM)
|
|
2926
|
+
except OSError:
|
|
2927
|
+
return
|
|
2928
|
+
deadline = _time.monotonic() + timeout
|
|
2929
|
+
while _time.monotonic() < deadline:
|
|
2930
|
+
with self._lock:
|
|
2931
|
+
if self.state in {"finished", "failed", "stopped"}:
|
|
2932
|
+
return
|
|
2933
|
+
_time.sleep(0.05)
|
|
2934
|
+
try:
|
|
2935
|
+
os.killpg(os.getpgid(pid), _signal.SIGKILL)
|
|
2936
|
+
except (OSError, ProcessLookupError):
|
|
2937
|
+
try:
|
|
2938
|
+
os.kill(pid, _signal.SIGKILL)
|
|
2939
|
+
except OSError:
|
|
2940
|
+
return
|
|
2941
|
+
|
|
2942
|
+
|
|
2943
|
+
class WorkshopTerminalRegistry:
|
|
2944
|
+
def __init__(self) -> None:
|
|
2945
|
+
import threading
|
|
2946
|
+
self._sessions: dict[str, WorkshopTerminalSession] = {}
|
|
2947
|
+
self._lock = threading.Lock()
|
|
2948
|
+
self._counter = 0
|
|
2949
|
+
|
|
2950
|
+
def create(self, command: list[str], cwd: Path, *, label: str = "") -> WorkshopTerminalSession:
|
|
2951
|
+
if not workshop_terminals_available():
|
|
2952
|
+
raise ValueError("PTY backend is not available on this host.")
|
|
2953
|
+
if not command or not isinstance(command, list):
|
|
2954
|
+
raise ValueError("Terminal command must be a non-empty list.")
|
|
2955
|
+
if not cwd.is_dir():
|
|
2956
|
+
raise ValueError("Workspace root is unavailable.")
|
|
2957
|
+
with self._lock:
|
|
2958
|
+
self._counter += 1
|
|
2959
|
+
session_id = f"wt-{self._counter:06d}"
|
|
2960
|
+
session = WorkshopTerminalSession(session_id=session_id, command=command, cwd=cwd, label=label)
|
|
2961
|
+
with self._lock:
|
|
2962
|
+
self._prune_locked()
|
|
2963
|
+
self._sessions[session_id] = session
|
|
2964
|
+
session.start()
|
|
2965
|
+
return session
|
|
2966
|
+
|
|
2967
|
+
def get(self, session_id: str) -> WorkshopTerminalSession | None:
|
|
2968
|
+
with self._lock:
|
|
2969
|
+
return self._sessions.get(session_id)
|
|
2970
|
+
|
|
2971
|
+
def list(self) -> list[dict[str, Any]]:
|
|
2972
|
+
with self._lock:
|
|
2973
|
+
self._prune_locked()
|
|
2974
|
+
return [session.status_payload() for session in self._sessions.values()]
|
|
2975
|
+
|
|
2976
|
+
def _prune_locked(self) -> None:
|
|
2977
|
+
for sid in list(self._sessions.keys()):
|
|
2978
|
+
if self._sessions[sid].is_expired():
|
|
2979
|
+
del self._sessions[sid]
|
|
2980
|
+
|
|
2981
|
+
def shutdown(self) -> None:
|
|
2982
|
+
with self._lock:
|
|
2983
|
+
sessions = list(self._sessions.values())
|
|
2984
|
+
for session in sessions:
|
|
2985
|
+
session.stop(timeout=1.0)
|
|
2986
|
+
|
|
2987
|
+
|
|
2988
|
+
def workshop_terminal_default_command() -> list[str]:
|
|
2989
|
+
return _default_workshop_shell()
|
|
2990
|
+
|
|
2991
|
+
|
|
2119
2992
|
class LogicsViewerServer(ThreadingHTTPServer):
|
|
2120
2993
|
def __init__(
|
|
2121
2994
|
self,
|
|
@@ -2123,6 +2996,8 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
2123
2996
|
repo_root: Path,
|
|
2124
2997
|
*,
|
|
2125
2998
|
auto_refresh_interval_seconds: int = 15,
|
|
2999
|
+
auto_refresh_interval_forced: bool = False,
|
|
3000
|
+
lan_mode: bool = False,
|
|
2126
3001
|
):
|
|
2127
3002
|
self.launch_repo_root = repo_root.resolve()
|
|
2128
3003
|
self.project_roots = discover_viewer_project_roots(self.launch_repo_root)
|
|
@@ -2130,18 +3005,41 @@ class LogicsViewerServer(ThreadingHTTPServer):
|
|
|
2130
3005
|
self.active_project_id = _viewer_project_id(self.launch_repo_root)
|
|
2131
3006
|
self.repo_root = self.launch_repo_root
|
|
2132
3007
|
self.auto_refresh_interval_seconds = auto_refresh_interval_seconds
|
|
3008
|
+
self.auto_refresh_interval_forced = auto_refresh_interval_forced
|
|
3009
|
+
self.lan_mode = bool(lan_mode)
|
|
3010
|
+
self.lan_token = secrets.token_urlsafe(32) if self.lan_mode else ""
|
|
3011
|
+
self.workshop_sessions = WorkshopSessionRegistry()
|
|
3012
|
+
self.workshop_terminals = WorkshopTerminalRegistry()
|
|
2133
3013
|
super().__init__(server_address, LogicsViewerRequestHandler)
|
|
2134
3014
|
|
|
3015
|
+
def server_close(self) -> None:
|
|
3016
|
+
try:
|
|
3017
|
+
self.workshop_sessions.shutdown()
|
|
3018
|
+
finally:
|
|
3019
|
+
try:
|
|
3020
|
+
self.workshop_terminals.shutdown()
|
|
3021
|
+
finally:
|
|
3022
|
+
super().server_close()
|
|
3023
|
+
|
|
2135
3024
|
def project_registry_payload(self) -> list[dict[str, Any]]:
|
|
2136
3025
|
return viewer_project_registry(self.repo_root, project_roots=self.project_roots)
|
|
2137
3026
|
|
|
2138
3027
|
def viewer_payload(self, *, selected_id: str | None = None) -> dict[str, Any]:
|
|
2139
|
-
|
|
3028
|
+
payload = viewer_data_payload(
|
|
2140
3029
|
self.repo_root,
|
|
2141
3030
|
selected_id=selected_id,
|
|
2142
3031
|
auto_refresh_interval_seconds=self.auto_refresh_interval_seconds,
|
|
3032
|
+
auto_refresh_interval_forced=self.auto_refresh_interval_forced,
|
|
2143
3033
|
projects=self.project_registry_payload(),
|
|
2144
3034
|
)
|
|
3035
|
+
payload["lanMode"] = bool(self.lan_mode)
|
|
3036
|
+
if self.lan_mode and self.lan_token:
|
|
3037
|
+
host, port = self.server_address[:2]
|
|
3038
|
+
lan_url = _network_viewer_url(str(host), int(port)) or build_viewer_url(str(host), int(port))
|
|
3039
|
+
payload["lanShareUrl"] = _append_lan_token(lan_url, self.lan_token)
|
|
3040
|
+
else:
|
|
3041
|
+
payload["lanShareUrl"] = ""
|
|
3042
|
+
return payload
|
|
2145
3043
|
|
|
2146
3044
|
def switch_project(self, project_id: str) -> dict[str, Any]:
|
|
2147
3045
|
target = self.project_root_by_id.get(project_id)
|
|
@@ -2185,8 +3083,175 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2185
3083
|
content_type = f"{content_type}; charset=utf-8"
|
|
2186
3084
|
self._send_bytes(path.read_bytes(), content_type=content_type)
|
|
2187
3085
|
|
|
3086
|
+
def _stream_workshop_terminal(self, session: "WorkshopTerminalSession", parsed: Any) -> None:
|
|
3087
|
+
import time as _time
|
|
3088
|
+
try:
|
|
3089
|
+
since = int(parse_qs(parsed.query).get("since", ["0"])[0])
|
|
3090
|
+
except (TypeError, ValueError):
|
|
3091
|
+
since = 0
|
|
3092
|
+
try:
|
|
3093
|
+
self.send_response(200)
|
|
3094
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
3095
|
+
self.send_header("Cache-Control", "no-store")
|
|
3096
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
3097
|
+
self.end_headers()
|
|
3098
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3099
|
+
return
|
|
3100
|
+
session.attach_listener()
|
|
3101
|
+
try:
|
|
3102
|
+
last_seq = since
|
|
3103
|
+
idle_ticks = 0
|
|
3104
|
+
while True:
|
|
3105
|
+
latest_seq, snapshot = session.tail(last_seq)
|
|
3106
|
+
if snapshot:
|
|
3107
|
+
idle_ticks = 0
|
|
3108
|
+
for seq, chunk in snapshot:
|
|
3109
|
+
last_seq = seq
|
|
3110
|
+
try:
|
|
3111
|
+
payload = json.dumps({"seq": seq, "data": chunk})
|
|
3112
|
+
self.wfile.write(f"event: data\ndata: {payload}\n\n".encode("utf-8"))
|
|
3113
|
+
self.wfile.flush()
|
|
3114
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3115
|
+
return
|
|
3116
|
+
state = session.state
|
|
3117
|
+
if state in {"finished", "failed", "stopped", "error"} and last_seq >= latest_seq:
|
|
3118
|
+
try:
|
|
3119
|
+
payload = json.dumps(session.status_payload())
|
|
3120
|
+
self.wfile.write(f"event: end\ndata: {payload}\n\n".encode("utf-8"))
|
|
3121
|
+
self.wfile.flush()
|
|
3122
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3123
|
+
return
|
|
3124
|
+
return
|
|
3125
|
+
idle_ticks += 1
|
|
3126
|
+
if idle_ticks >= 30:
|
|
3127
|
+
try:
|
|
3128
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
3129
|
+
self.wfile.flush()
|
|
3130
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3131
|
+
return
|
|
3132
|
+
idle_ticks = 0
|
|
3133
|
+
_time.sleep(0.1)
|
|
3134
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3135
|
+
return
|
|
3136
|
+
finally:
|
|
3137
|
+
session.detach_listener()
|
|
3138
|
+
|
|
3139
|
+
def _stream_workshop_session(self, session: "WorkshopCommandSession", parsed: Any) -> None:
|
|
3140
|
+
import time as _time
|
|
3141
|
+
try:
|
|
3142
|
+
since = int(parse_qs(parsed.query).get("since", ["0"])[0])
|
|
3143
|
+
except (TypeError, ValueError):
|
|
3144
|
+
since = 0
|
|
3145
|
+
try:
|
|
3146
|
+
self.send_response(200)
|
|
3147
|
+
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
|
|
3148
|
+
self.send_header("Cache-Control", "no-store")
|
|
3149
|
+
self.send_header("X-Accel-Buffering", "no")
|
|
3150
|
+
self.end_headers()
|
|
3151
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3152
|
+
return
|
|
3153
|
+
last_seq = since
|
|
3154
|
+
idle_ticks = 0
|
|
3155
|
+
try:
|
|
3156
|
+
while True:
|
|
3157
|
+
latest_seq, snapshot = session.tail(last_seq)
|
|
3158
|
+
if snapshot:
|
|
3159
|
+
idle_ticks = 0
|
|
3160
|
+
for seq, line in snapshot:
|
|
3161
|
+
last_seq = seq
|
|
3162
|
+
try:
|
|
3163
|
+
channel, _, text = line.partition("\t")
|
|
3164
|
+
payload = json.dumps({"seq": seq, "channel": channel, "line": text})
|
|
3165
|
+
self.wfile.write(f"event: line\ndata: {payload}\n\n".encode("utf-8"))
|
|
3166
|
+
self.wfile.flush()
|
|
3167
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3168
|
+
return
|
|
3169
|
+
state = session.state
|
|
3170
|
+
if state in {"finished", "failed", "stopped", "error"} and last_seq >= latest_seq:
|
|
3171
|
+
try:
|
|
3172
|
+
payload = json.dumps(session.status_payload())
|
|
3173
|
+
self.wfile.write(f"event: end\ndata: {payload}\n\n".encode("utf-8"))
|
|
3174
|
+
self.wfile.flush()
|
|
3175
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3176
|
+
return
|
|
3177
|
+
return
|
|
3178
|
+
idle_ticks += 1
|
|
3179
|
+
if idle_ticks >= 30:
|
|
3180
|
+
try:
|
|
3181
|
+
self.wfile.write(b": keep-alive\n\n")
|
|
3182
|
+
self.wfile.flush()
|
|
3183
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3184
|
+
return
|
|
3185
|
+
idle_ticks = 0
|
|
3186
|
+
_time.sleep(0.2)
|
|
3187
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3188
|
+
return
|
|
3189
|
+
|
|
3190
|
+
def _client_is_loopback(self) -> bool:
|
|
3191
|
+
try:
|
|
3192
|
+
host = self.client_address[0]
|
|
3193
|
+
except (IndexError, AttributeError):
|
|
3194
|
+
return False
|
|
3195
|
+
if not host:
|
|
3196
|
+
return False
|
|
3197
|
+
if host in {"127.0.0.1", "::1"}:
|
|
3198
|
+
return True
|
|
3199
|
+
if host.startswith("127."):
|
|
3200
|
+
return True
|
|
3201
|
+
if host.startswith("::ffff:127."):
|
|
3202
|
+
return True
|
|
3203
|
+
return False
|
|
3204
|
+
|
|
3205
|
+
def _is_public_get_route(self, route: str) -> bool:
|
|
3206
|
+
"""Static UI assets that must load before the JS can attach the bearer.
|
|
3207
|
+
|
|
3208
|
+
Browsers do not auto-attach Authorization headers to <script src>,
|
|
3209
|
+
<link href>, or @font-face fetches, and we cannot put ?t= on every
|
|
3210
|
+
asset URL the page references. We let these routes through
|
|
3211
|
+
unauthenticated; they expose no repository data — every actual
|
|
3212
|
+
payload lives under /api/* which stays gated.
|
|
3213
|
+
"""
|
|
3214
|
+
if route in {"/", "/browser-host.js", "/viewer.css", "/vendor/mermaid.min.js"}:
|
|
3215
|
+
return True
|
|
3216
|
+
if route.startswith("/media/"):
|
|
3217
|
+
return True
|
|
3218
|
+
return False
|
|
3219
|
+
|
|
3220
|
+
def _lan_auth_passes(self, parsed: Any, *, method: str = "GET") -> bool:
|
|
3221
|
+
token = self.server.lan_token
|
|
3222
|
+
if not token:
|
|
3223
|
+
return True
|
|
3224
|
+
if self._client_is_loopback():
|
|
3225
|
+
return True
|
|
3226
|
+
if method == "GET" and self._is_public_get_route(parsed.path):
|
|
3227
|
+
return True
|
|
3228
|
+
header = self.headers.get("Authorization", "")
|
|
3229
|
+
if header.lower().startswith("bearer "):
|
|
3230
|
+
candidate = header.split(" ", 1)[1].strip()
|
|
3231
|
+
if candidate and hmac.compare_digest(candidate, token):
|
|
3232
|
+
return True
|
|
3233
|
+
query_token = (parse_qs(parsed.query).get("t") or [""])[0]
|
|
3234
|
+
if query_token and hmac.compare_digest(query_token, token):
|
|
3235
|
+
return True
|
|
3236
|
+
return False
|
|
3237
|
+
|
|
3238
|
+
def _send_lan_unauthorized(self) -> None:
|
|
3239
|
+
body = _json_bytes({"ok": False, "error": "LAN viewer requires a bearer token. Open the share URL from the launch banner."})
|
|
3240
|
+
try:
|
|
3241
|
+
self.send_response(HTTPStatus.UNAUTHORIZED)
|
|
3242
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
3243
|
+
self.send_header("Cache-Control", "no-store")
|
|
3244
|
+
self.send_header("WWW-Authenticate", 'Bearer realm="logics-viewer"')
|
|
3245
|
+
self.end_headers()
|
|
3246
|
+
self.wfile.write(body)
|
|
3247
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
3248
|
+
return
|
|
3249
|
+
|
|
2188
3250
|
def do_GET(self) -> None:
|
|
2189
3251
|
parsed = urlparse(self.path)
|
|
3252
|
+
if not self._lan_auth_passes(parsed, method="GET"):
|
|
3253
|
+
self._send_lan_unauthorized()
|
|
3254
|
+
return
|
|
2190
3255
|
route = parsed.path
|
|
2191
3256
|
if route == "/":
|
|
2192
3257
|
self._serve_file(VIEWER_ROOT / "index.html")
|
|
@@ -2266,10 +3331,91 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2266
3331
|
rel_path = params.get("path", [""])[0]
|
|
2267
3332
|
self._send_json({"ok": True, "payload": git_file_preview_payload(self.server.repo_root, rel_path)})
|
|
2268
3333
|
return
|
|
3334
|
+
if route == "/api/workspace-tree":
|
|
3335
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
3336
|
+
try:
|
|
3337
|
+
self._send_json({"ok": True, "payload": workspace_tree_payload(self.server.repo_root, rel_path)})
|
|
3338
|
+
except ValueError as exc:
|
|
3339
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3340
|
+
return
|
|
3341
|
+
if route == "/api/workspace-preview":
|
|
3342
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
3343
|
+
try:
|
|
3344
|
+
self._send_json({"ok": True, "payload": workspace_preview_payload(self.server.repo_root, rel_path)})
|
|
3345
|
+
except ValueError as exc:
|
|
3346
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3347
|
+
return
|
|
3348
|
+
if route == "/api/workshop-commands":
|
|
3349
|
+
try:
|
|
3350
|
+
self._send_json({"ok": True, "payload": workshop_commands_payload(self.server.repo_root)})
|
|
3351
|
+
except ValueError as exc:
|
|
3352
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3353
|
+
return
|
|
3354
|
+
if route == "/api/workshop-sessions":
|
|
3355
|
+
self._send_json({"ok": True, "payload": {"sessions": self.server.workshop_sessions.list()}})
|
|
3356
|
+
return
|
|
3357
|
+
if route == "/api/workshop-terminals":
|
|
3358
|
+
self._send_json({"ok": True, "payload": {"sessions": self.server.workshop_terminals.list(), "available": workshop_terminals_available()}})
|
|
3359
|
+
return
|
|
3360
|
+
if route.startswith("/api/workshop-terminal/"):
|
|
3361
|
+
tail = route[len("/api/workshop-terminal/"):]
|
|
3362
|
+
parts = tail.split("/", 1)
|
|
3363
|
+
session_id = parts[0]
|
|
3364
|
+
kind = parts[1] if len(parts) > 1 else "status"
|
|
3365
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3366
|
+
if session is None:
|
|
3367
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3368
|
+
return
|
|
3369
|
+
if kind == "status":
|
|
3370
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3371
|
+
return
|
|
3372
|
+
if kind == "stream":
|
|
3373
|
+
self._stream_workshop_terminal(session, parsed)
|
|
3374
|
+
return
|
|
3375
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown terminal sub-resource.")
|
|
3376
|
+
return
|
|
3377
|
+
if route.startswith("/api/workshop-session/"):
|
|
3378
|
+
tail = route[len("/api/workshop-session/"):]
|
|
3379
|
+
parts = tail.split("/", 1)
|
|
3380
|
+
session_id = parts[0]
|
|
3381
|
+
kind = parts[1] if len(parts) > 1 else "status"
|
|
3382
|
+
session = self.server.workshop_sessions.get(session_id)
|
|
3383
|
+
if session is None:
|
|
3384
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop session not found.")
|
|
3385
|
+
return
|
|
3386
|
+
if kind == "status":
|
|
3387
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3388
|
+
return
|
|
3389
|
+
if kind == "stream":
|
|
3390
|
+
self._stream_workshop_session(session, parsed)
|
|
3391
|
+
return
|
|
3392
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown session sub-resource.")
|
|
3393
|
+
return
|
|
3394
|
+
if route == "/api/workspace-file":
|
|
3395
|
+
rel_path = parse_qs(parsed.query).get("path", [""])[0]
|
|
3396
|
+
try:
|
|
3397
|
+
payload = workspace_preview_payload(self.server.repo_root, rel_path)
|
|
3398
|
+
if payload.get("state") != "image":
|
|
3399
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Workspace file is not an image preview.")
|
|
3400
|
+
return
|
|
3401
|
+
_normalized, absolute = _resolve_workspace_path(self.server.repo_root, rel_path)
|
|
3402
|
+
self._serve_file(absolute)
|
|
3403
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
3404
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
3405
|
+
return
|
|
2269
3406
|
self._send_error_json(HTTPStatus.NOT_FOUND, "Not found")
|
|
2270
3407
|
|
|
2271
3408
|
def do_POST(self) -> None:
|
|
2272
3409
|
parsed = urlparse(self.path)
|
|
3410
|
+
if not self._lan_auth_passes(parsed, method="POST"):
|
|
3411
|
+
self._send_lan_unauthorized()
|
|
3412
|
+
return
|
|
3413
|
+
if self.server.lan_mode and parsed.path in VIEWER_MUTATING_ROUTES:
|
|
3414
|
+
self._send_error_json(
|
|
3415
|
+
HTTPStatus.FORBIDDEN,
|
|
3416
|
+
"Mutating endpoint refused: the viewer is exposed on the LAN in read-only mode.",
|
|
3417
|
+
)
|
|
3418
|
+
return
|
|
2273
3419
|
if parsed.path == "/api/refresh":
|
|
2274
3420
|
self._send_json(
|
|
2275
3421
|
{
|
|
@@ -2292,6 +3438,105 @@ class LogicsViewerRequestHandler(BaseHTTPRequestHandler):
|
|
|
2292
3438
|
except FileNotFoundError as exc:
|
|
2293
3439
|
self._send_error_json(HTTPStatus.NOT_FOUND, str(exc))
|
|
2294
3440
|
return
|
|
3441
|
+
if parsed.path == "/api/workshop-command-start":
|
|
3442
|
+
try:
|
|
3443
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3444
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3445
|
+
body = json.loads(raw_body or "{}")
|
|
3446
|
+
command_id = str(body.get("commandId") or "")
|
|
3447
|
+
if not command_id:
|
|
3448
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Missing commandId.")
|
|
3449
|
+
return
|
|
3450
|
+
catalog = workshop_commands_payload(self.server.repo_root)
|
|
3451
|
+
entry = next((c for c in catalog.get("commands", []) if c.get("id") == command_id), None)
|
|
3452
|
+
if entry is None:
|
|
3453
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Unknown command id.")
|
|
3454
|
+
return
|
|
3455
|
+
session = self.server.workshop_sessions.create(entry, self.server.repo_root)
|
|
3456
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3457
|
+
except json.JSONDecodeError:
|
|
3458
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3459
|
+
except ValueError as exc:
|
|
3460
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3461
|
+
return
|
|
3462
|
+
if parsed.path == "/api/workshop-terminal-start":
|
|
3463
|
+
try:
|
|
3464
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3465
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3466
|
+
body = json.loads(raw_body or "{}")
|
|
3467
|
+
command_override = body.get("command")
|
|
3468
|
+
label = str(body.get("label") or "")
|
|
3469
|
+
command = command_override if isinstance(command_override, list) and all(isinstance(p, str) for p in command_override) and command_override else workshop_terminal_default_command()
|
|
3470
|
+
session = self.server.workshop_terminals.create(command, self.server.repo_root, label=label)
|
|
3471
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3472
|
+
except json.JSONDecodeError:
|
|
3473
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3474
|
+
except ValueError as exc:
|
|
3475
|
+
self._send_error_json(HTTPStatus.FORBIDDEN, str(exc))
|
|
3476
|
+
return
|
|
3477
|
+
if parsed.path == "/api/workshop-terminal-input":
|
|
3478
|
+
try:
|
|
3479
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3480
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3481
|
+
body = json.loads(raw_body or "{}")
|
|
3482
|
+
session_id = str(body.get("sessionId") or "")
|
|
3483
|
+
data = str(body.get("data") or "")
|
|
3484
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3485
|
+
if session is None:
|
|
3486
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3487
|
+
return
|
|
3488
|
+
session.write(data)
|
|
3489
|
+
self._send_json({"ok": True})
|
|
3490
|
+
except json.JSONDecodeError:
|
|
3491
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3492
|
+
return
|
|
3493
|
+
if parsed.path == "/api/workshop-terminal-resize":
|
|
3494
|
+
try:
|
|
3495
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3496
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3497
|
+
body = json.loads(raw_body or "{}")
|
|
3498
|
+
session_id = str(body.get("sessionId") or "")
|
|
3499
|
+
rows = int(body.get("rows") or 0)
|
|
3500
|
+
cols = int(body.get("cols") or 0)
|
|
3501
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3502
|
+
if session is None:
|
|
3503
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3504
|
+
return
|
|
3505
|
+
session.resize(rows, cols)
|
|
3506
|
+
self._send_json({"ok": True})
|
|
3507
|
+
except (json.JSONDecodeError, ValueError):
|
|
3508
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid resize body.")
|
|
3509
|
+
return
|
|
3510
|
+
if parsed.path == "/api/workshop-terminal-stop":
|
|
3511
|
+
try:
|
|
3512
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3513
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3514
|
+
body = json.loads(raw_body or "{}")
|
|
3515
|
+
session_id = str(body.get("sessionId") or "")
|
|
3516
|
+
session = self.server.workshop_terminals.get(session_id)
|
|
3517
|
+
if session is None:
|
|
3518
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop terminal not found.")
|
|
3519
|
+
return
|
|
3520
|
+
session.stop()
|
|
3521
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3522
|
+
except json.JSONDecodeError:
|
|
3523
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3524
|
+
return
|
|
3525
|
+
if parsed.path == "/api/workshop-command-stop":
|
|
3526
|
+
try:
|
|
3527
|
+
length = int(self.headers.get("Content-Length", "0") or "0")
|
|
3528
|
+
raw_body = self.rfile.read(length).decode("utf-8") if length > 0 else "{}"
|
|
3529
|
+
body = json.loads(raw_body or "{}")
|
|
3530
|
+
session_id = str(body.get("sessionId") or "")
|
|
3531
|
+
session = self.server.workshop_sessions.get(session_id)
|
|
3532
|
+
if session is None:
|
|
3533
|
+
self._send_error_json(HTTPStatus.NOT_FOUND, "Workshop session not found.")
|
|
3534
|
+
return
|
|
3535
|
+
session.stop()
|
|
3536
|
+
self._send_json({"ok": True, "payload": session.status_payload()})
|
|
3537
|
+
except json.JSONDecodeError:
|
|
3538
|
+
self._send_error_json(HTTPStatus.BAD_REQUEST, "Invalid JSON body.")
|
|
3539
|
+
return
|
|
2295
3540
|
if parsed.path == "/api/bootstrap-logics":
|
|
2296
3541
|
try:
|
|
2297
3542
|
bootstrap = bootstrap_payload(self.server.repo_root, check=False)
|
|
@@ -2394,22 +3639,82 @@ def create_viewer_server(
|
|
|
2394
3639
|
port: int = 8765,
|
|
2395
3640
|
*,
|
|
2396
3641
|
auto_refresh_interval_seconds: int = 15,
|
|
3642
|
+
auto_refresh_interval_forced: bool = False,
|
|
3643
|
+
lan_mode: bool = False,
|
|
2397
3644
|
) -> LogicsViewerServer:
|
|
2398
3645
|
return LogicsViewerServer(
|
|
2399
3646
|
(host, port),
|
|
2400
3647
|
repo_root,
|
|
2401
3648
|
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
|
|
3649
|
+
auto_refresh_interval_forced=auto_refresh_interval_forced,
|
|
3650
|
+
lan_mode=lan_mode,
|
|
2402
3651
|
)
|
|
2403
3652
|
|
|
2404
3653
|
|
|
2405
|
-
def
|
|
2406
|
-
if
|
|
2407
|
-
return
|
|
3654
|
+
def _render_qr_lines(url: str) -> list[str]:
|
|
3655
|
+
if not url:
|
|
3656
|
+
return []
|
|
2408
3657
|
try:
|
|
2409
|
-
|
|
3658
|
+
import segno # type: ignore
|
|
3659
|
+
except ImportError:
|
|
3660
|
+
return [
|
|
3661
|
+
"+" + "-" * (len(url) + 2) + "+",
|
|
3662
|
+
"| " + url + " |",
|
|
3663
|
+
"+" + "-" * (len(url) + 2) + "+",
|
|
3664
|
+
"(Install the optional `segno` package to render a scannable QR matrix.)",
|
|
3665
|
+
]
|
|
3666
|
+
try:
|
|
3667
|
+
qr = segno.make(url, error="m")
|
|
3668
|
+
buffer: list[str] = []
|
|
3669
|
+
qr.terminal(out=type("Buf", (), {"write": lambda self, value: buffer.append(value)})(), border=1)
|
|
3670
|
+
# segno's terminal output ends each line with newline; flatten back into lines.
|
|
3671
|
+
return ("".join(buffer)).splitlines() or [url]
|
|
3672
|
+
except Exception:
|
|
3673
|
+
return [url]
|
|
3674
|
+
|
|
3675
|
+
|
|
3676
|
+
def _append_lan_token(url: str, token: str) -> str:
|
|
3677
|
+
if not url or not token:
|
|
3678
|
+
return url
|
|
3679
|
+
sep = "&" if "?" in url else "?"
|
|
3680
|
+
return f"{url}{sep}t={quote(token, safe='')}"
|
|
3681
|
+
|
|
3682
|
+
|
|
3683
|
+
def _detect_lan_ip() -> str:
|
|
3684
|
+
"""Best-effort detection of the host's primary LAN IPv4 address.
|
|
3685
|
+
|
|
3686
|
+
Uses the standard UDP-socket trick: open a non-blocking connection to a
|
|
3687
|
+
routable but unreachable target and read the local socket name. This
|
|
3688
|
+
yields the address the OS would use for outbound traffic, which is the
|
|
3689
|
+
one a phone on the same LAN should target.
|
|
3690
|
+
"""
|
|
3691
|
+
candidate = ""
|
|
3692
|
+
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
3693
|
+
try:
|
|
3694
|
+
s.setblocking(False)
|
|
3695
|
+
try:
|
|
3696
|
+
s.connect(("10.255.255.255", 1))
|
|
3697
|
+
candidate = s.getsockname()[0]
|
|
3698
|
+
except OSError:
|
|
3699
|
+
candidate = ""
|
|
3700
|
+
finally:
|
|
3701
|
+
s.close()
|
|
3702
|
+
if candidate and not candidate.startswith("127."):
|
|
3703
|
+
return candidate
|
|
3704
|
+
try:
|
|
3705
|
+
fallback = socket.gethostbyname(socket.gethostname())
|
|
2410
3706
|
except OSError:
|
|
3707
|
+
return ""
|
|
3708
|
+
if fallback and not fallback.startswith("127."):
|
|
3709
|
+
return fallback
|
|
3710
|
+
return ""
|
|
3711
|
+
|
|
3712
|
+
|
|
3713
|
+
def _network_viewer_url(host: str, port: int, *, focus: str | None = None, read: bool = False) -> str | None:
|
|
3714
|
+
if host not in {"0.0.0.0", "::", ""}:
|
|
2411
3715
|
return None
|
|
2412
|
-
|
|
3716
|
+
candidate = _detect_lan_ip()
|
|
3717
|
+
if not candidate:
|
|
2413
3718
|
return None
|
|
2414
3719
|
return build_viewer_url(candidate, port, focus=focus, read=read)
|
|
2415
3720
|
|
|
@@ -2422,13 +3727,18 @@ def render_start_status(
|
|
|
2422
3727
|
network_url: str | None = None,
|
|
2423
3728
|
bind_host: str = "localhost",
|
|
2424
3729
|
auto_refresh_interval_seconds: int = 15,
|
|
3730
|
+
lan_mode: bool = False,
|
|
3731
|
+
lan_token: str | None = None,
|
|
3732
|
+
lan_url: str | None = None,
|
|
3733
|
+
qr_lines: list[str] | None = None,
|
|
2425
3734
|
) -> str:
|
|
3735
|
+
mode_label = "LAN read-only (token required)" if lan_mode else "read-only"
|
|
2426
3736
|
lines = [
|
|
2427
3737
|
"Logics viewer running:",
|
|
2428
3738
|
f"Local: {url}",
|
|
2429
3739
|
"",
|
|
2430
3740
|
f"Repo: {repo_root.name}",
|
|
2431
|
-
"Mode:
|
|
3741
|
+
f"Mode: {mode_label}",
|
|
2432
3742
|
f"Bind: {bind_host}",
|
|
2433
3743
|
f"Auto refresh: {auto_refresh_interval_seconds}s",
|
|
2434
3744
|
]
|
|
@@ -2436,6 +3746,16 @@ def render_start_status(
|
|
|
2436
3746
|
lines.insert(2, f"Network: {network_url}")
|
|
2437
3747
|
if focus:
|
|
2438
3748
|
lines.append(f"Focus: {focus}")
|
|
3749
|
+
if lan_mode:
|
|
3750
|
+
lines.append("")
|
|
3751
|
+
lines.append("LAN exposure is active. Mutating endpoints are refused; non-loopback clients must present the session token below.")
|
|
3752
|
+
if lan_url:
|
|
3753
|
+
lines.append(f"Share URL: {lan_url}")
|
|
3754
|
+
if lan_token:
|
|
3755
|
+
lines.append(f"Token: {lan_token}")
|
|
3756
|
+
if qr_lines:
|
|
3757
|
+
lines.append("")
|
|
3758
|
+
lines.extend(qr_lines)
|
|
2439
3759
|
return "\n".join(lines)
|
|
2440
3760
|
|
|
2441
3761
|
|
|
@@ -2443,10 +3763,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2443
3763
|
parser = argparse.ArgumentParser(prog="logics-manager view", description="Start the local read-only Logics browser viewer.")
|
|
2444
3764
|
parser.add_argument("--host", default="127.0.0.1", help="Bind host. Defaults to 127.0.0.1.")
|
|
2445
3765
|
parser.add_argument("--port", type=int, default=8765, help="Bind port. Use 0 to select an available port.")
|
|
3766
|
+
parser.add_argument(
|
|
3767
|
+
"--lan",
|
|
3768
|
+
action="store_true",
|
|
3769
|
+
help="Expose the viewer on the local network (0.0.0.0). Enforces read-only access and requires a per-session bearer token for non-loopback requests.",
|
|
3770
|
+
)
|
|
2446
3771
|
parser.add_argument(
|
|
2447
3772
|
"--refresh-interval",
|
|
2448
3773
|
type=int,
|
|
2449
|
-
default=
|
|
3774
|
+
default=None,
|
|
2450
3775
|
help="Automatic refresh interval in seconds. Defaults to 15; positive intervals are allowed.",
|
|
2451
3776
|
)
|
|
2452
3777
|
parser.add_argument("--focus", help="Open the viewer focused on a workflow ref or repo-relative Logics Markdown path.")
|
|
@@ -2459,7 +3784,9 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
2459
3784
|
def main(argv: list[str]) -> int:
|
|
2460
3785
|
args = build_parser().parse_args(argv)
|
|
2461
3786
|
repo_root = find_repo_root(Path.cwd())
|
|
2462
|
-
|
|
3787
|
+
refresh_interval_forced = args.refresh_interval is not None
|
|
3788
|
+
refresh_interval = args.refresh_interval if args.refresh_interval is not None else 15
|
|
3789
|
+
if refresh_interval <= 0:
|
|
2463
3790
|
raise SystemExit("--refresh-interval must be a positive number of seconds.")
|
|
2464
3791
|
if args.read and not args.focus:
|
|
2465
3792
|
raise SystemExit("--read requires --focus.")
|
|
@@ -2467,15 +3794,24 @@ def main(argv: list[str]) -> int:
|
|
|
2467
3794
|
focus = normalize_viewer_focus_target(repo_root, args.focus) if args.focus else None
|
|
2468
3795
|
except ValueError as exc:
|
|
2469
3796
|
raise SystemExit(str(exc)) from exc
|
|
3797
|
+
bind_host = "0.0.0.0" if args.lan and args.host == "127.0.0.1" else args.host
|
|
2470
3798
|
server = create_viewer_server(
|
|
2471
3799
|
repo_root,
|
|
2472
|
-
host=
|
|
3800
|
+
host=bind_host,
|
|
2473
3801
|
port=args.port,
|
|
2474
|
-
auto_refresh_interval_seconds=
|
|
3802
|
+
auto_refresh_interval_seconds=refresh_interval,
|
|
3803
|
+
auto_refresh_interval_forced=refresh_interval_forced,
|
|
3804
|
+
lan_mode=bool(args.lan),
|
|
2475
3805
|
)
|
|
2476
3806
|
host, port = server.server_address[:2]
|
|
2477
3807
|
url = build_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
2478
3808
|
network_url = _network_viewer_url(str(host), int(port), focus=focus, read=bool(args.read))
|
|
3809
|
+
lan_share_url = ""
|
|
3810
|
+
qr_lines: list[str] = []
|
|
3811
|
+
if args.lan and server.lan_token:
|
|
3812
|
+
base_for_lan = network_url or url
|
|
3813
|
+
lan_share_url = _append_lan_token(base_for_lan, server.lan_token)
|
|
3814
|
+
qr_lines = _render_qr_lines(lan_share_url)
|
|
2479
3815
|
print(
|
|
2480
3816
|
render_start_status(
|
|
2481
3817
|
url,
|
|
@@ -2483,7 +3819,11 @@ def main(argv: list[str]) -> int:
|
|
|
2483
3819
|
focus=focus,
|
|
2484
3820
|
network_url=network_url,
|
|
2485
3821
|
bind_host=str(host),
|
|
2486
|
-
auto_refresh_interval_seconds=
|
|
3822
|
+
auto_refresh_interval_seconds=refresh_interval,
|
|
3823
|
+
lan_mode=bool(args.lan),
|
|
3824
|
+
lan_token=server.lan_token if args.lan else None,
|
|
3825
|
+
lan_url=lan_share_url or None,
|
|
3826
|
+
qr_lines=qr_lines or None,
|
|
2487
3827
|
),
|
|
2488
3828
|
flush=True,
|
|
2489
3829
|
)
|