collab-runtime 0.4.0__tar.gz → 0.4.2__tar.gz
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.
- {collab_runtime-0.4.0/collab_runtime.egg-info → collab_runtime-0.4.2}/PKG-INFO +1 -1
- collab_runtime-0.4.2/collab/dashboard/dashboard-format.js +74 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/dashboard/index.html +26 -8
- collab_runtime-0.4.2/collab/dashboard_server.py +507 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/live_locks_watcher.py +22 -1
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/lock_client.py +150 -10
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/platform_probe.py +41 -1
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/safe_subprocess.py +14 -5
- {collab_runtime-0.4.0 → collab_runtime-0.4.2/collab_runtime.egg-info}/PKG-INFO +1 -1
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/SOURCES.txt +1 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/pyproject.toml +5 -2
- collab_runtime-0.4.0/collab/dashboard_server.py +0 -250
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/LICENSE +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/README.md +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/__init__.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/__main__.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/agent_identity.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/errors.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/logging_config.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/main.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab/subprocess_bridge.py +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/dependency_links.txt +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/entry_points.txt +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/requires.txt +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/collab_runtime.egg-info/top_level.txt +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/docs/pypi/README.md +0 -0
- {collab_runtime-0.4.0 → collab_runtime-0.4.2}/setup.cfg +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure formatting and routing helpers for the Collaborative Lock Dashboard.
|
|
3
|
+
* Loaded in the browser (global DashboardFormat) and in Jest (module.exports).
|
|
4
|
+
*/
|
|
5
|
+
(function (root, factory) {
|
|
6
|
+
const api = factory();
|
|
7
|
+
if (typeof module === "object" && module.exports) {
|
|
8
|
+
module.exports = api;
|
|
9
|
+
} else {
|
|
10
|
+
root.DashboardFormat = api;
|
|
11
|
+
}
|
|
12
|
+
})(typeof globalThis !== "undefined" ? globalThis : this, function () {
|
|
13
|
+
function formatDateLong(dt) {
|
|
14
|
+
return dt.toLocaleDateString([], {
|
|
15
|
+
year: "numeric",
|
|
16
|
+
month: "long",
|
|
17
|
+
day: "numeric",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function formatTime24(dt) {
|
|
22
|
+
return dt.toLocaleTimeString([], {
|
|
23
|
+
hour: "2-digit",
|
|
24
|
+
minute: "2-digit",
|
|
25
|
+
hour12: false,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatDateTime24(dt) {
|
|
30
|
+
return formatDateLong(dt) + " " + formatTime24(dt);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function formatDurationMinutes(totalMinutes) {
|
|
34
|
+
const rounded = Math.max(0, Math.round(Number(totalMinutes) || 0));
|
|
35
|
+
if (!Number.isFinite(rounded) || rounded <= 0) {
|
|
36
|
+
return "0m";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const units = [
|
|
40
|
+
{ label: "mo", minutes: 30 * 24 * 60 },
|
|
41
|
+
{ label: "d", minutes: 24 * 60 },
|
|
42
|
+
{ label: "h", minutes: 60 },
|
|
43
|
+
{ label: "m", minutes: 1 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
let remaining = rounded;
|
|
47
|
+
const parts = [];
|
|
48
|
+
|
|
49
|
+
units.forEach((unit) => {
|
|
50
|
+
if (remaining >= unit.minutes) {
|
|
51
|
+
const value = Math.floor(remaining / unit.minutes);
|
|
52
|
+
remaining -= value * unit.minutes;
|
|
53
|
+
parts.push(String(value) + unit.label);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
return parts.length ? parts.join(" ") : "0m";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function routeFromHash(hashLike) {
|
|
61
|
+
const h = String(hashLike || "")
|
|
62
|
+
.replace("#", "")
|
|
63
|
+
.toLowerCase();
|
|
64
|
+
return h === "history" ? "history" : "locks";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
formatDateLong,
|
|
69
|
+
formatTime24,
|
|
70
|
+
formatDateTime24,
|
|
71
|
+
formatDurationMinutes,
|
|
72
|
+
routeFromHash,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
@@ -807,19 +807,37 @@ SUPABASE_ANON_KEY=your_anon_key</pre>
|
|
|
807
807
|
|
|
808
808
|
function updateProjectInfo() {
|
|
809
809
|
const el = document.getElementById("project-info");
|
|
810
|
-
|
|
810
|
+
const repoName = (runtimeCfg.projectName || "").trim();
|
|
811
|
+
let supabaseRef = "";
|
|
812
|
+
if (SUPABASE_URL) {
|
|
813
|
+
try {
|
|
814
|
+
supabaseRef = new URL(SUPABASE_URL).hostname.replace(
|
|
815
|
+
".supabase.co",
|
|
816
|
+
""
|
|
817
|
+
);
|
|
818
|
+
} catch (e) {
|
|
819
|
+
supabaseRef = "";
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
const display = repoName || supabaseRef;
|
|
823
|
+
if (!display) {
|
|
811
824
|
el.classList.add("hidden");
|
|
812
825
|
el.textContent = "";
|
|
813
826
|
return;
|
|
814
827
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
828
|
+
el.textContent = display;
|
|
829
|
+
const tip = [];
|
|
830
|
+
if (repoName) {
|
|
831
|
+
tip.push("Repository project: " + repoName);
|
|
832
|
+
}
|
|
833
|
+
if (supabaseRef) {
|
|
834
|
+
tip.push("Supabase project: " + supabaseRef);
|
|
835
|
+
}
|
|
836
|
+
if (SUPABASE_URL) {
|
|
837
|
+
tip.push(SUPABASE_URL);
|
|
822
838
|
}
|
|
839
|
+
el.title = tip.join("\n");
|
|
840
|
+
el.classList.remove("hidden");
|
|
823
841
|
}
|
|
824
842
|
|
|
825
843
|
async function syncRuntimeConfig() {
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
"""Local HTTP server for the collaborative dashboard static assets.
|
|
2
|
+
|
|
3
|
+
The dashboard HTML references sibling static files (e.g. ``dashboard-format.js``). The
|
|
4
|
+
injected config HTML must therefore be written *inside* ``collab/dashboard/`` and served
|
|
5
|
+
from that directory — not from a lone temp file in ``/tmp``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import atexit
|
|
11
|
+
import fnmatch
|
|
12
|
+
import http.server
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import tomllib
|
|
21
|
+
import urllib.parse
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any, Callable, Iterable, Optional, Sequence, Tuple
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
DASHBOARD_TEMP_PREFIX = ".collab-dashboard-"
|
|
29
|
+
RUNTIME_CONFIG_PATH = "/collab-runtime-config.json"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _repo_name_from_remote_url(url: str) -> Optional[str]:
|
|
33
|
+
"""Extract repo folder name from HTTPS or SCP-style git remote URLs."""
|
|
34
|
+
cleaned = (url or "").strip().rstrip("/")
|
|
35
|
+
if not cleaned:
|
|
36
|
+
return None
|
|
37
|
+
if cleaned.endswith(".git"):
|
|
38
|
+
cleaned = cleaned[:-4]
|
|
39
|
+
if ":" in cleaned and "@" in cleaned.split(":", 1)[0]:
|
|
40
|
+
cleaned = cleaned.rsplit(":", 1)[-1]
|
|
41
|
+
tail = cleaned.rsplit("/", 1)[-1].strip()
|
|
42
|
+
return tail or None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _name_from_pyproject(project_root: str) -> Optional[str]:
|
|
46
|
+
"""Return ``[project].name`` from ``pyproject.toml`` when present."""
|
|
47
|
+
path = os.path.join(project_root, "pyproject.toml")
|
|
48
|
+
if not os.path.isfile(path):
|
|
49
|
+
return None
|
|
50
|
+
try:
|
|
51
|
+
with open(path, "rb") as fh:
|
|
52
|
+
data = tomllib.load(fh)
|
|
53
|
+
name = data.get("project", {}).get("name")
|
|
54
|
+
if isinstance(name, str) and name.strip():
|
|
55
|
+
return name.strip()
|
|
56
|
+
except (OSError, ValueError, TypeError, tomllib.TOMLDecodeError) as exc:
|
|
57
|
+
logger.debug("Could not read pyproject.toml project name: %s", exc)
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _name_from_package_json(project_root: str) -> Optional[str]:
|
|
62
|
+
"""Return ``name`` from ``package.json`` when present."""
|
|
63
|
+
path = os.path.join(project_root, "package.json")
|
|
64
|
+
if not os.path.isfile(path):
|
|
65
|
+
return None
|
|
66
|
+
try:
|
|
67
|
+
with open(path, encoding="utf-8") as fh:
|
|
68
|
+
data = json.load(fh)
|
|
69
|
+
name = data.get("name")
|
|
70
|
+
if isinstance(name, str) and name.strip():
|
|
71
|
+
return name.strip()
|
|
72
|
+
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
|
73
|
+
logger.debug("Could not read package.json name: %s", exc)
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _name_from_git_remote(project_root: str) -> Optional[str]:
|
|
78
|
+
"""Return the repository folder name from ``remote.origin.url`` (git config)."""
|
|
79
|
+
from collab import safe_subprocess
|
|
80
|
+
|
|
81
|
+
captured = safe_subprocess.capture(
|
|
82
|
+
["git", "config", "--get", "remote.origin.url"],
|
|
83
|
+
policy="git",
|
|
84
|
+
cwd=project_root,
|
|
85
|
+
timeout=5,
|
|
86
|
+
)
|
|
87
|
+
if not captured.ok or captured.timed_out:
|
|
88
|
+
return None
|
|
89
|
+
remote = safe_subprocess.decode_output(captured.stdout).strip()
|
|
90
|
+
return _repo_name_from_remote_url(remote)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_project_display_name(
|
|
94
|
+
project_root: str,
|
|
95
|
+
file_vals: Optional[dict[str, Any]] = None,
|
|
96
|
+
) -> str:
|
|
97
|
+
"""Return a human-friendly label for the repository Collab is serving.
|
|
98
|
+
|
|
99
|
+
Precedence (repository-first, as shown in the dashboard header):
|
|
100
|
+
|
|
101
|
+
1. ``COLLAB_PROJECT_NAME`` (``.env`` or environment)
|
|
102
|
+
2. Git ``origin`` remote repository name (e.g. ``collab`` from ``.../collab.git``)
|
|
103
|
+
3. ``pyproject.toml`` ``[project].name``
|
|
104
|
+
4. ``package.json`` ``name``
|
|
105
|
+
5. Basename of *project_root*
|
|
106
|
+
"""
|
|
107
|
+
vals = file_vals if file_vals is not None else {}
|
|
108
|
+
override = vals.get("COLLAB_PROJECT_NAME") or os.getenv("COLLAB_PROJECT_NAME") or ""
|
|
109
|
+
if isinstance(override, str) and override.strip():
|
|
110
|
+
return override.strip()
|
|
111
|
+
|
|
112
|
+
for resolver in (
|
|
113
|
+
lambda: _name_from_git_remote(project_root),
|
|
114
|
+
lambda: _name_from_pyproject(project_root),
|
|
115
|
+
lambda: _name_from_package_json(project_root),
|
|
116
|
+
):
|
|
117
|
+
name = resolver()
|
|
118
|
+
if name:
|
|
119
|
+
return name
|
|
120
|
+
|
|
121
|
+
return os.path.basename(os.path.abspath(project_root)) or "project"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
|
|
125
|
+
"""Read ``.env`` from *project_root* and return dashboard Supabase settings.
|
|
126
|
+
|
|
127
|
+
The local dashboard fetches this on each sync so credential changes take effect
|
|
128
|
+
without restarting the watcher or reopening a stale browser tab. The project
|
|
129
|
+
``.env`` is read directly (not via ``load_dotenv``) so polling never mutates the
|
|
130
|
+
running watcher's process environment.
|
|
131
|
+
"""
|
|
132
|
+
from dotenv import dotenv_values
|
|
133
|
+
|
|
134
|
+
env_path = os.path.join(project_root, ".env")
|
|
135
|
+
file_vals: dict[str, Any] = {}
|
|
136
|
+
if os.path.isfile(env_path):
|
|
137
|
+
try:
|
|
138
|
+
file_vals = dict(dotenv_values(env_path))
|
|
139
|
+
except OSError as exc:
|
|
140
|
+
logger.debug("Could not read %s for runtime config: %s", env_path, exc)
|
|
141
|
+
|
|
142
|
+
def pick(name: str) -> Optional[str]:
|
|
143
|
+
val = file_vals.get(name)
|
|
144
|
+
if not val:
|
|
145
|
+
val = os.getenv(name)
|
|
146
|
+
return val
|
|
147
|
+
|
|
148
|
+
url = pick("SUPABASE_URL") or ""
|
|
149
|
+
anon = pick("SUPABASE_ANON_KEY") or ""
|
|
150
|
+
service = pick("SUPABASE_SERVICE_ROLE_KEY") or None
|
|
151
|
+
user = (
|
|
152
|
+
pick("COLLAB_DEVELOPER_ID")
|
|
153
|
+
or pick("DEVELOPER_ID")
|
|
154
|
+
or os.getenv("USERNAME")
|
|
155
|
+
or os.getenv("USER")
|
|
156
|
+
or ""
|
|
157
|
+
)
|
|
158
|
+
from . import agent_identity
|
|
159
|
+
|
|
160
|
+
state_override = os.getenv("COLLAB_STATE_DIR", "").strip()
|
|
161
|
+
state_dir = state_override or os.path.join(project_root, ".collab")
|
|
162
|
+
agent_id = agent_identity.resolve_agent_id(state_dir)
|
|
163
|
+
agent_label = agent_identity.resolve_agent_label()
|
|
164
|
+
project_name = resolve_project_display_name(project_root, file_vals)
|
|
165
|
+
return {
|
|
166
|
+
"url": url,
|
|
167
|
+
"anonKey": anon,
|
|
168
|
+
"serviceKey": service,
|
|
169
|
+
"user": user,
|
|
170
|
+
"agentId": agent_id,
|
|
171
|
+
"agentLabel": agent_label,
|
|
172
|
+
"projectName": project_name,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def create_dashboard_handler(project_root: str, directory: str) -> type:
|
|
177
|
+
"""Build a request handler that serves static assets and live runtime config."""
|
|
178
|
+
|
|
179
|
+
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|
180
|
+
"""Serve dashboard static files without per-request stderr logging."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
183
|
+
super().__init__(*args, directory=directory, **kwargs)
|
|
184
|
+
|
|
185
|
+
def log_message(self, format: str, *args: Any) -> None:
|
|
186
|
+
"""Suppress default SimpleHTTPRequestHandler request log lines."""
|
|
187
|
+
|
|
188
|
+
def do_GET(self) -> None:
|
|
189
|
+
parsed = urllib.parse.urlparse(self.path)
|
|
190
|
+
if parsed.path == RUNTIME_CONFIG_PATH:
|
|
191
|
+
self._serve_runtime_config()
|
|
192
|
+
return
|
|
193
|
+
super().do_GET()
|
|
194
|
+
|
|
195
|
+
def _serve_runtime_config(self) -> None:
|
|
196
|
+
payload = load_runtime_supabase_config(project_root)
|
|
197
|
+
body = json.dumps(payload).encode("utf-8")
|
|
198
|
+
self.send_response(200)
|
|
199
|
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
200
|
+
self.send_header("Content-Length", str(len(body)))
|
|
201
|
+
self.send_header("Cache-Control", "no-store")
|
|
202
|
+
self.end_headers()
|
|
203
|
+
self.wfile.write(body)
|
|
204
|
+
|
|
205
|
+
return DashboardHandler
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def dashboard_directory(resource_root: str) -> str:
|
|
209
|
+
"""Return the path to packaged dashboard static assets."""
|
|
210
|
+
return os.path.join(resource_root, "dashboard")
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# --- Dashboard static-asset packaging guards -------------------------------
|
|
214
|
+
#
|
|
215
|
+
# index.html loads sibling assets (e.g. ``dashboard-format.js``). If those files
|
|
216
|
+
# are not shipped in the wheel ``[tool.setuptools.package-data]`` the browser gets
|
|
217
|
+
# a 404 and the dashboard renders blank. The helpers below let the runtime warn on
|
|
218
|
+
# a broken install and let tests prove every referenced/shipped asset is packaged.
|
|
219
|
+
|
|
220
|
+
_PACKAGE_DASHBOARD_PREFIX = "dashboard/"
|
|
221
|
+
_WHEEL_DASHBOARD_PREFIX = "collab/dashboard/"
|
|
222
|
+
|
|
223
|
+
_LOCAL_SCRIPT_SRC = re.compile(
|
|
224
|
+
r'<script[^>]+src=["\'](?!https?://)([^"\']+)["\']',
|
|
225
|
+
re.IGNORECASE,
|
|
226
|
+
)
|
|
227
|
+
_LOCAL_LINK_HREF = re.compile(
|
|
228
|
+
r'<link[^>]+href=["\'](?!https?://)([^"\']+)["\']',
|
|
229
|
+
re.IGNORECASE,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _normalize_static_ref(ref: str) -> Optional[str]:
|
|
234
|
+
"""Strip query/fragment and reject absolute or protocol-relative refs."""
|
|
235
|
+
path = ref.split("?", 1)[0].split("#", 1)[0].strip()
|
|
236
|
+
if not path or path.startswith("/"):
|
|
237
|
+
return None
|
|
238
|
+
return path.replace("\\", "/")
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def local_static_refs_from_html(html: str) -> Tuple[str, ...]:
|
|
242
|
+
"""Return sorted relative script/link paths referenced by dashboard HTML.
|
|
243
|
+
|
|
244
|
+
CDN (``https://``) and absolute (``/foo``) references are ignored; only assets
|
|
245
|
+
that must ship inside the package are returned.
|
|
246
|
+
"""
|
|
247
|
+
refs: list[str] = []
|
|
248
|
+
for pattern in (_LOCAL_SCRIPT_SRC, _LOCAL_LINK_HREF):
|
|
249
|
+
for raw in pattern.findall(html):
|
|
250
|
+
normalized = _normalize_static_ref(raw)
|
|
251
|
+
if normalized:
|
|
252
|
+
refs.append(normalized)
|
|
253
|
+
return tuple(sorted(set(refs)))
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def shipped_dashboard_relative_paths(resource_root: str) -> Tuple[str, ...]:
|
|
257
|
+
"""Return dashboard-relative paths of files that must ship in the wheel.
|
|
258
|
+
|
|
259
|
+
Hidden files (e.g. injected ``.collab-dashboard-*`` temp HTML) are excluded.
|
|
260
|
+
"""
|
|
261
|
+
dash_dir = Path(dashboard_directory(resource_root))
|
|
262
|
+
if not dash_dir.is_dir():
|
|
263
|
+
return ()
|
|
264
|
+
return tuple(
|
|
265
|
+
path.relative_to(dash_dir).as_posix()
|
|
266
|
+
for path in sorted(dash_dir.rglob("*"))
|
|
267
|
+
if path.is_file() and not path.name.startswith(".")
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def missing_local_static_files(resource_root: str, html: str) -> Tuple[str, ...]:
|
|
272
|
+
"""Return local refs in *html* that are absent on disk under the dashboard dir."""
|
|
273
|
+
dash_dir = Path(dashboard_directory(resource_root))
|
|
274
|
+
return tuple(
|
|
275
|
+
rel
|
|
276
|
+
for rel in local_static_refs_from_html(html)
|
|
277
|
+
if not (dash_dir / rel).is_file()
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def verify_dashboard_static_assets(resource_root: str) -> Tuple[str, ...]:
|
|
282
|
+
"""Log and return any local assets referenced by index.html but missing.
|
|
283
|
+
|
|
284
|
+
Called on each template read so a broken wheel (or partial dev tree) surfaces a
|
|
285
|
+
clear error instead of a silent blank dashboard.
|
|
286
|
+
"""
|
|
287
|
+
dash_dir = Path(dashboard_directory(resource_root))
|
|
288
|
+
index_path = dash_dir / "index.html"
|
|
289
|
+
if not index_path.is_file():
|
|
290
|
+
logger.error("Dashboard template missing at %s", index_path)
|
|
291
|
+
return ("index.html",)
|
|
292
|
+
missing = missing_local_static_files(
|
|
293
|
+
resource_root, index_path.read_text(encoding="utf-8")
|
|
294
|
+
)
|
|
295
|
+
for rel in missing:
|
|
296
|
+
logger.error(
|
|
297
|
+
"Dashboard static asset missing at %s (broken wheel or dev tree)",
|
|
298
|
+
dash_dir / rel,
|
|
299
|
+
)
|
|
300
|
+
return missing
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def read_package_data_patterns(pyproject_path: str) -> Tuple[str, ...]:
|
|
304
|
+
"""Return ``[tool.setuptools.package-data].collab`` glob patterns."""
|
|
305
|
+
with open(pyproject_path, "rb") as fh:
|
|
306
|
+
data = tomllib.load(fh)
|
|
307
|
+
patterns = (
|
|
308
|
+
data.get("tool", {})
|
|
309
|
+
.get("setuptools", {})
|
|
310
|
+
.get("package-data", {})
|
|
311
|
+
.get("collab", [])
|
|
312
|
+
)
|
|
313
|
+
if isinstance(patterns, str):
|
|
314
|
+
return (patterns,)
|
|
315
|
+
if isinstance(patterns, list):
|
|
316
|
+
return tuple(str(p) for p in patterns)
|
|
317
|
+
return ()
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def package_data_covers(relative_path: str, patterns: Sequence[str]) -> bool:
|
|
321
|
+
"""Return True when a dashboard-relative path matches a package-data glob."""
|
|
322
|
+
for pattern in patterns:
|
|
323
|
+
if not pattern.startswith(_PACKAGE_DASHBOARD_PREFIX):
|
|
324
|
+
continue
|
|
325
|
+
suffix = pattern[len(_PACKAGE_DASHBOARD_PREFIX) :]
|
|
326
|
+
if suffix == "**":
|
|
327
|
+
return True
|
|
328
|
+
if fnmatch.fnmatch(relative_path, suffix):
|
|
329
|
+
return True
|
|
330
|
+
return False
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def missing_package_data_coverage(
|
|
334
|
+
resource_root: str, patterns: Sequence[str]
|
|
335
|
+
) -> Tuple[str, ...]:
|
|
336
|
+
"""Return shipped dashboard files not matched by any package-data glob."""
|
|
337
|
+
return tuple(
|
|
338
|
+
rel
|
|
339
|
+
for rel in shipped_dashboard_relative_paths(resource_root)
|
|
340
|
+
if not package_data_covers(rel, patterns)
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def wheel_dashboard_member_paths(wheel_path: str) -> Tuple[str, ...]:
|
|
345
|
+
"""Return dashboard-relative paths contained in a built wheel archive."""
|
|
346
|
+
members: set[str] = set()
|
|
347
|
+
with zipfile.ZipFile(wheel_path) as archive:
|
|
348
|
+
for name in archive.namelist():
|
|
349
|
+
normalized = name.replace("\\", "/")
|
|
350
|
+
if normalized.startswith(_WHEEL_DASHBOARD_PREFIX) and not (
|
|
351
|
+
normalized.endswith("/")
|
|
352
|
+
):
|
|
353
|
+
members.add(normalized[len(_WHEEL_DASHBOARD_PREFIX) :])
|
|
354
|
+
return tuple(sorted(members))
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def missing_wheel_dashboard_files(
|
|
358
|
+
wheel_path: str, required: Iterable[str]
|
|
359
|
+
) -> Tuple[str, ...]:
|
|
360
|
+
"""Return required dashboard paths absent from a built wheel."""
|
|
361
|
+
present = set(wheel_dashboard_member_paths(wheel_path))
|
|
362
|
+
return tuple(sorted(set(required) - present))
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def read_dashboard_template(resource_root: str) -> Optional[str]:
|
|
366
|
+
"""Read ``index.html`` from the dashboard package directory."""
|
|
367
|
+
verify_dashboard_static_assets(resource_root)
|
|
368
|
+
html_path = os.path.join(dashboard_directory(resource_root), "index.html")
|
|
369
|
+
if not os.path.exists(html_path):
|
|
370
|
+
logger.error("Dashboard file not found at %s", html_path)
|
|
371
|
+
return None
|
|
372
|
+
try:
|
|
373
|
+
with open(html_path, "r", encoding="utf-8") as fh:
|
|
374
|
+
return fh.read()
|
|
375
|
+
except OSError as exc:
|
|
376
|
+
logger.error("Error reading dashboard template: %s", exc)
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def write_injected_dashboard_html(
|
|
381
|
+
resource_root: str, injected: dict[str, Any]
|
|
382
|
+
) -> Optional[str]:
|
|
383
|
+
"""Write config-injected HTML next to static assets; return path or None."""
|
|
384
|
+
content = read_dashboard_template(resource_root)
|
|
385
|
+
if content is None:
|
|
386
|
+
return None
|
|
387
|
+
|
|
388
|
+
dash_dir = dashboard_directory(resource_root)
|
|
389
|
+
inject_script = (
|
|
390
|
+
f"<script>window.__SUPABASE_CONFIG__ = {json.dumps(injected)};</script>\n"
|
|
391
|
+
)
|
|
392
|
+
try:
|
|
393
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
394
|
+
mode="w",
|
|
395
|
+
delete=False,
|
|
396
|
+
suffix=".html",
|
|
397
|
+
prefix=DASHBOARD_TEMP_PREFIX,
|
|
398
|
+
dir=dash_dir,
|
|
399
|
+
encoding="utf-8",
|
|
400
|
+
)
|
|
401
|
+
tmp.write(inject_script)
|
|
402
|
+
tmp.write(content)
|
|
403
|
+
tmp.flush()
|
|
404
|
+
tmp.close()
|
|
405
|
+
return tmp.name
|
|
406
|
+
except OSError as exc:
|
|
407
|
+
logger.error("Error creating temp dashboard file: %s", exc)
|
|
408
|
+
return None
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _register_temp_html_cleanup(html_path: str) -> None:
|
|
412
|
+
"""Remove generated dashboard HTML on process exit."""
|
|
413
|
+
|
|
414
|
+
def _unlink() -> None:
|
|
415
|
+
try:
|
|
416
|
+
os.unlink(html_path)
|
|
417
|
+
except OSError:
|
|
418
|
+
pass
|
|
419
|
+
|
|
420
|
+
atexit.register(_unlink)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def start_dashboard_http_server(
|
|
424
|
+
resource_root: str,
|
|
425
|
+
injected_html_path: str,
|
|
426
|
+
*,
|
|
427
|
+
project_root: Optional[str] = None,
|
|
428
|
+
log_error: Callable[[str, Any], None] = logger.error,
|
|
429
|
+
log_warning: Callable[[str, Any], None] = logger.warning,
|
|
430
|
+
) -> Optional[str]:
|
|
431
|
+
"""Serve ``collab/dashboard`` and return the URL to the injected HTML file."""
|
|
432
|
+
dash_dir = dashboard_directory(resource_root)
|
|
433
|
+
filename = os.path.basename(injected_html_path)
|
|
434
|
+
env_root = project_root or resource_root
|
|
435
|
+
|
|
436
|
+
try:
|
|
437
|
+
handler_cls = create_dashboard_handler(env_root, dash_dir)
|
|
438
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
|
439
|
+
port = server.server_address[1]
|
|
440
|
+
|
|
441
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
442
|
+
thread.start()
|
|
443
|
+
|
|
444
|
+
def _safe_shutdown() -> None:
|
|
445
|
+
try:
|
|
446
|
+
server.shutdown()
|
|
447
|
+
except BaseException:
|
|
448
|
+
pass
|
|
449
|
+
close = getattr(server, "server_close", None)
|
|
450
|
+
if callable(close):
|
|
451
|
+
try:
|
|
452
|
+
close()
|
|
453
|
+
except OSError:
|
|
454
|
+
pass
|
|
455
|
+
|
|
456
|
+
atexit.register(_safe_shutdown)
|
|
457
|
+
_register_temp_html_cleanup(injected_html_path)
|
|
458
|
+
|
|
459
|
+
url = f"http://127.0.0.1:{port}/{filename}"
|
|
460
|
+
|
|
461
|
+
import socket
|
|
462
|
+
|
|
463
|
+
for _ in range(20):
|
|
464
|
+
try:
|
|
465
|
+
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
|
466
|
+
break
|
|
467
|
+
except OSError:
|
|
468
|
+
time.sleep(0.05)
|
|
469
|
+
|
|
470
|
+
return url
|
|
471
|
+
except OSError as exc:
|
|
472
|
+
log_error("Failed to start local dashboard server: %s", exc)
|
|
473
|
+
try:
|
|
474
|
+
os.unlink(injected_html_path)
|
|
475
|
+
except OSError as cleanup_exc:
|
|
476
|
+
log_warning("Dashboard temp-file cleanup failed: %s", cleanup_exc)
|
|
477
|
+
return None
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def prepare_dashboard_server(
|
|
481
|
+
resource_root: str,
|
|
482
|
+
injected: dict[str, Any],
|
|
483
|
+
*,
|
|
484
|
+
project_root: Optional[str] = None,
|
|
485
|
+
log_error: Callable[[str, Any], None] = logger.error,
|
|
486
|
+
log_warning: Callable[[str, Any], None] = logger.warning,
|
|
487
|
+
) -> Tuple[Optional[str], Optional[str]]:
|
|
488
|
+
"""Write injected HTML, start server from dashboard dir; return (url, path)."""
|
|
489
|
+
env_root = project_root or resource_root
|
|
490
|
+
enriched = {
|
|
491
|
+
**injected,
|
|
492
|
+
"projectName": resolve_project_display_name(env_root),
|
|
493
|
+
}
|
|
494
|
+
html_path = write_injected_dashboard_html(resource_root, enriched)
|
|
495
|
+
if not html_path:
|
|
496
|
+
return None, None
|
|
497
|
+
|
|
498
|
+
url = start_dashboard_http_server(
|
|
499
|
+
resource_root,
|
|
500
|
+
html_path,
|
|
501
|
+
project_root=project_root,
|
|
502
|
+
log_error=log_error,
|
|
503
|
+
log_warning=log_warning,
|
|
504
|
+
)
|
|
505
|
+
if not url:
|
|
506
|
+
return None, None
|
|
507
|
+
return url, html_path
|
|
@@ -282,6 +282,27 @@ def _git_capture_text(argv: list[str], *, cwd: str | None = None) -> str:
|
|
|
282
282
|
return ""
|
|
283
283
|
|
|
284
284
|
|
|
285
|
+
def _git_capture_status_porcelain() -> str:
|
|
286
|
+
"""Return ``git status --porcelain`` stdout, preserving leading whitespace.
|
|
287
|
+
|
|
288
|
+
Unlike :func:`_git_capture_text`, this trims only surrounding newlines and never
|
|
289
|
+
performs a full ``.strip()``. Porcelain lines begin with a 2-column status field
|
|
290
|
+
(XY) whose first column is a space for worktree-only changes (e.g. ``" M path"``).
|
|
291
|
+
Stripping the whole blob would remove the leading space of the FIRST line, shifting
|
|
292
|
+
the fixed-width parse in :func:`_parse_git_status_path` and silently dropping the
|
|
293
|
+
first character of that path.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
captured = safe_subprocess.capture(
|
|
297
|
+
["git", "status", "--porcelain"], policy="git", cwd=_PROJECT_ROOT
|
|
298
|
+
)
|
|
299
|
+
if captured.ok:
|
|
300
|
+
return safe_subprocess.decode_output(captured.stdout).strip("\r\n")
|
|
301
|
+
except Exception as exc:
|
|
302
|
+
logger.debug("git status --porcelain failed: %s", exc)
|
|
303
|
+
return ""
|
|
304
|
+
|
|
305
|
+
|
|
285
306
|
def _get_developer_id() -> str:
|
|
286
307
|
"""Derive developer identity from git config or environment."""
|
|
287
308
|
try:
|
|
@@ -795,7 +816,7 @@ def _get_modified_and_unpushed_files() -> set[str]:
|
|
|
795
816
|
|
|
796
817
|
# Part 1: dirty/staged files
|
|
797
818
|
try:
|
|
798
|
-
out =
|
|
819
|
+
out = _git_capture_status_porcelain()
|
|
799
820
|
if out:
|
|
800
821
|
for line in out.splitlines():
|
|
801
822
|
if len(line) > 3:
|
|
@@ -1720,9 +1720,11 @@ class LockClient:
|
|
|
1720
1720
|
# processes for this workspace if the PID file is missing or stale.
|
|
1721
1721
|
pid = self._read_pid()
|
|
1722
1722
|
pids_to_stop: List[int] = []
|
|
1723
|
+
watcher_found = False
|
|
1723
1724
|
|
|
1724
1725
|
if pid and self._is_process_alive(pid):
|
|
1725
1726
|
pids_to_stop = [pid]
|
|
1727
|
+
watcher_found = True
|
|
1726
1728
|
else:
|
|
1727
1729
|
# Safety rail: during tests, never discover/stop external watcher
|
|
1728
1730
|
# processes when the module is still using the production PID file.
|
|
@@ -1740,21 +1742,17 @@ class LockClient:
|
|
|
1740
1742
|
self._remove_pid()
|
|
1741
1743
|
return
|
|
1742
1744
|
|
|
1743
|
-
# Attempt to discover live watcher processes related to this repo
|
|
1745
|
+
# Attempt to discover live watcher processes related to this repo.
|
|
1746
|
+
# Note: even when no Python watcher is found we still fall through
|
|
1747
|
+
# to the launcher-reaping step below, because an orphaned
|
|
1748
|
+
# ``collab.exe`` wrapper can outlive the watcher it spawned.
|
|
1744
1749
|
try:
|
|
1745
1750
|
found = self._discover_running_watchers()
|
|
1746
1751
|
if found:
|
|
1747
1752
|
pids_to_stop = found
|
|
1748
|
-
|
|
1749
|
-
print("No running watcher found.")
|
|
1750
|
-
logger.info("No running watcher found for this workspace")
|
|
1751
|
-
self._remove_pid()
|
|
1752
|
-
return
|
|
1753
|
+
watcher_found = True
|
|
1753
1754
|
except Exception as e:
|
|
1754
1755
|
logger.debug("Watcher discovery failed: %s", e)
|
|
1755
|
-
print("No running watcher found.")
|
|
1756
|
-
self._remove_pid()
|
|
1757
|
-
return
|
|
1758
1756
|
|
|
1759
1757
|
# Stop each discovered watcher PID (soft stop first, then force)
|
|
1760
1758
|
for target_pid in pids_to_stop:
|
|
@@ -1864,6 +1862,25 @@ class LockClient:
|
|
|
1864
1862
|
logger.info("Stopped watcher (PID: %d) (forced)", target_pid)
|
|
1865
1863
|
print("✅ Stopped.")
|
|
1866
1864
|
|
|
1865
|
+
# Defense-in-depth (Windows): reap orphaned ``collab.exe`` /
|
|
1866
|
+
# ``collab-watcher.exe`` console-script wrappers in this namespace.
|
|
1867
|
+
# These keep the venv ``.exe`` image locked (EBUSY on delete) and can
|
|
1868
|
+
# outlive the Python watcher when started by an older IDE extension.
|
|
1869
|
+
# Give a well-behaved wrapper a brief moment to exit on its own first.
|
|
1870
|
+
if pids_to_stop:
|
|
1871
|
+
time.sleep(0.5)
|
|
1872
|
+
reaped = self._reap_collab_launchers()
|
|
1873
|
+
if reaped:
|
|
1874
|
+
logger.info("Reaped %d orphaned collab launcher wrapper(s)", reaped)
|
|
1875
|
+
print(
|
|
1876
|
+
f"✅ Cleaned up {reaped} leftover collab launcher "
|
|
1877
|
+
f"process(es) locking the virtualenv."
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1880
|
+
if not watcher_found and not reaped:
|
|
1881
|
+
print("No running watcher found.")
|
|
1882
|
+
logger.info("No running watcher found for this workspace")
|
|
1883
|
+
|
|
1867
1884
|
# Final cleanup: ensure canonical PID file removed
|
|
1868
1885
|
try:
|
|
1869
1886
|
self._remove_pid()
|
|
@@ -3335,7 +3352,13 @@ class LockClient:
|
|
|
3335
3352
|
return ""
|
|
3336
3353
|
if not captured.ok:
|
|
3337
3354
|
return ""
|
|
3338
|
-
|
|
3355
|
+
# NOTE: Only trim surrounding newlines, never a full ``.strip()``.
|
|
3356
|
+
# ``git status --porcelain`` lines begin with a 2-column status field
|
|
3357
|
+
# (XY) whose first column is a space for worktree-only changes (e.g.
|
|
3358
|
+
# " M path"). A full strip would remove the leading space of the FIRST
|
|
3359
|
+
# line, shifting the fixed-width parse in ``_parse_git_status_path`` and
|
|
3360
|
+
# silently dropping the first character of that path.
|
|
3361
|
+
return safe_subprocess.decode_output(captured.stdout).strip("\r\n")
|
|
3339
3362
|
|
|
3340
3363
|
@staticmethod
|
|
3341
3364
|
def _git_ref_exists(ref: str) -> bool:
|
|
@@ -3950,6 +3973,123 @@ class LockClient:
|
|
|
3950
3973
|
continue
|
|
3951
3974
|
return found
|
|
3952
3975
|
|
|
3976
|
+
def _launcher_cmdline_in_namespace(self, cmdline: str) -> bool:
|
|
3977
|
+
"""Return True for a watcher-launcher cmdline in this PID-file namespace.
|
|
3978
|
+
|
|
3979
|
+
Used to identify pip console-script wrappers (``collab.exe`` / ``collab-
|
|
3980
|
+
watcher.exe``) that launched *this* workspace's watcher. Requires a ``watch``
|
|
3981
|
+
invocation and a ``--pid-file`` matching the current namespace so launchers from
|
|
3982
|
+
unrelated workspaces are never targeted.
|
|
3983
|
+
"""
|
|
3984
|
+
if not cmdline:
|
|
3985
|
+
return False
|
|
3986
|
+
if "watch" not in cmdline.lower():
|
|
3987
|
+
return False
|
|
3988
|
+
return self._cmdline_matches_current_pid_namespace(cmdline)
|
|
3989
|
+
|
|
3990
|
+
def _discover_collab_launcher_pids(self) -> List[int]:
|
|
3991
|
+
"""Find running collab console-script launcher wrappers for this namespace.
|
|
3992
|
+
|
|
3993
|
+
Windows-only. The pip-generated ``collab.exe`` / ``collab-watcher.exe`` wrappers
|
|
3994
|
+
keep their own image file open for their entire lifetime, so an orphaned wrapper
|
|
3995
|
+
(e.g. spawned by an older IDE extension) blocks deletion of the virtualenv long
|
|
3996
|
+
after the underlying Python watcher has exited. Returns candidate launcher PIDs
|
|
3997
|
+
to reap (may be empty).
|
|
3998
|
+
"""
|
|
3999
|
+
if sys.platform != "win32":
|
|
4000
|
+
return []
|
|
4001
|
+
|
|
4002
|
+
launcher_names = {"collab.exe", "collab-watcher.exe"}
|
|
4003
|
+
candidates: set[int] = set()
|
|
4004
|
+
self_pid = os.getpid()
|
|
4005
|
+
|
|
4006
|
+
# Fast path: psutil enumeration.
|
|
4007
|
+
try:
|
|
4008
|
+
import psutil
|
|
4009
|
+
|
|
4010
|
+
for p in psutil.process_iter(attrs=("pid", "name", "cmdline")):
|
|
4011
|
+
try:
|
|
4012
|
+
pid = int(p.info.get("pid") or 0)
|
|
4013
|
+
if pid <= 0 or pid == self_pid:
|
|
4014
|
+
continue
|
|
4015
|
+
name = (p.info.get("name") or "").lower()
|
|
4016
|
+
if name not in launcher_names:
|
|
4017
|
+
continue
|
|
4018
|
+
cmdline = p.info.get("cmdline")
|
|
4019
|
+
cmd_str = (
|
|
4020
|
+
" ".join(cmdline)
|
|
4021
|
+
if isinstance(cmdline, (list, tuple))
|
|
4022
|
+
else str(cmdline or "")
|
|
4023
|
+
)
|
|
4024
|
+
if self._launcher_cmdline_in_namespace(cmd_str):
|
|
4025
|
+
candidates.add(pid)
|
|
4026
|
+
except Exception:
|
|
4027
|
+
continue
|
|
4028
|
+
return sorted(candidates)
|
|
4029
|
+
except Exception as exc:
|
|
4030
|
+
logger.debug("psutil launcher discovery unavailable/failed: %s", exc)
|
|
4031
|
+
|
|
4032
|
+
# Fallback: tasklist enumeration + per-PID cmdline lookup.
|
|
4033
|
+
try:
|
|
4034
|
+
for pid in platform_probe.iter_collab_launcher_pids():
|
|
4035
|
+
if pid == self_pid:
|
|
4036
|
+
continue
|
|
4037
|
+
cmd = self._get_cmdline_for_pid(pid)
|
|
4038
|
+
if cmd and self._launcher_cmdline_in_namespace(cmd):
|
|
4039
|
+
candidates.add(pid)
|
|
4040
|
+
except Exception as exc:
|
|
4041
|
+
logger.debug("tasklist launcher discovery failed: %s", exc)
|
|
4042
|
+
return sorted(candidates)
|
|
4043
|
+
|
|
4044
|
+
def _reap_collab_launchers(self) -> int:
|
|
4045
|
+
"""Force-terminate orphaned collab launcher wrappers in this namespace.
|
|
4046
|
+
|
|
4047
|
+
Windows-only defense-in-depth for ``daemon_stop``. The Python watcher is now
|
|
4048
|
+
launched via the interpreter, but older/already-deployed IDE extensions launched
|
|
4049
|
+
it through the ``collab.exe`` console-script wrapper. That wrapper can be left
|
|
4050
|
+
running (holding the venv ``.exe`` locked) even after the watcher PID is
|
|
4051
|
+
stopped. Reaping it makes the virtualenv deletable. Returns the number of
|
|
4052
|
+
wrappers terminated.
|
|
4053
|
+
"""
|
|
4054
|
+
if sys.platform != "win32" or _is_test_mode():
|
|
4055
|
+
return 0
|
|
4056
|
+
|
|
4057
|
+
try:
|
|
4058
|
+
launchers = self._discover_collab_launcher_pids()
|
|
4059
|
+
except Exception as exc:
|
|
4060
|
+
logger.debug("collab launcher discovery failed: %s", exc)
|
|
4061
|
+
return 0
|
|
4062
|
+
|
|
4063
|
+
skip = {os.getpid()}
|
|
4064
|
+
try:
|
|
4065
|
+
skip.add(os.getppid())
|
|
4066
|
+
except Exception:
|
|
4067
|
+
pass
|
|
4068
|
+
|
|
4069
|
+
reaped = 0
|
|
4070
|
+
for lpid in launchers:
|
|
4071
|
+
if lpid in skip:
|
|
4072
|
+
continue
|
|
4073
|
+
if not self._is_process_alive(lpid):
|
|
4074
|
+
continue
|
|
4075
|
+
logger.info(
|
|
4076
|
+
"Reaping orphaned collab launcher wrapper (PID: %d) holding venv .exe",
|
|
4077
|
+
lpid,
|
|
4078
|
+
)
|
|
4079
|
+
platform_probe.taskkill_force(lpid, tree=True)
|
|
4080
|
+
# Confirm termination; log if it stubbornly survives.
|
|
4081
|
+
for _ in range(10):
|
|
4082
|
+
if not self._is_process_alive(lpid):
|
|
4083
|
+
break
|
|
4084
|
+
time.sleep(0.2)
|
|
4085
|
+
if self._is_process_alive(lpid):
|
|
4086
|
+
logger.warning(
|
|
4087
|
+
"Collab launcher wrapper (PID: %d) survived reap attempt", lpid
|
|
4088
|
+
)
|
|
4089
|
+
else:
|
|
4090
|
+
reaped += 1
|
|
4091
|
+
return reaped
|
|
4092
|
+
|
|
3953
4093
|
def _read_pid_file(self) -> Optional[Dict[str, Any]]:
|
|
3954
4094
|
"""Read the PID file and return the metadata dictionary if available."""
|
|
3955
4095
|
if not os.path.exists(PID_FILE):
|
|
@@ -20,6 +20,11 @@ logger = logging.getLogger("collab.platform_probe")
|
|
|
20
20
|
|
|
21
21
|
_WIN_CREATION_FLAGS = 0x08000000
|
|
22
22
|
_PYTHON_IMAGE_NAMES = frozenset({"python.exe", "pythonw.exe", "python3.exe"})
|
|
23
|
+
# pip-generated console-script wrappers for the collab runtime. On Windows these
|
|
24
|
+
# ``.exe`` launchers hold their own image file open for the life of the process,
|
|
25
|
+
# which can block deletion of the virtualenv when a watcher was started through
|
|
26
|
+
# the wrapper (older IDE extensions) and the wrapper is left orphaned.
|
|
27
|
+
_COLLAB_LAUNCHER_IMAGE_NAMES = frozenset({"collab.exe", "collab-watcher.exe"})
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
def _require_pid(pid: int) -> int:
|
|
@@ -50,7 +55,7 @@ def _run_platform(
|
|
|
50
55
|
"timeout": timeout,
|
|
51
56
|
"text": text,
|
|
52
57
|
}
|
|
53
|
-
if
|
|
58
|
+
if os.name == "nt":
|
|
54
59
|
kwargs["creationflags"] = _WIN_CREATION_FLAGS
|
|
55
60
|
try:
|
|
56
61
|
completed = sp.run(list(argv), capture_output=True, **kwargs)
|
|
@@ -283,6 +288,41 @@ def ps_aux() -> str:
|
|
|
283
288
|
return _run_platform([exe, "aux"], timeout=60.0)
|
|
284
289
|
|
|
285
290
|
|
|
291
|
+
def iter_collab_launcher_pids() -> list[int]:
|
|
292
|
+
"""Collect PIDs for collab console-script launcher images (Windows).
|
|
293
|
+
|
|
294
|
+
Enumerates ``collab.exe`` and ``collab-watcher.exe`` processes via tasklist so
|
|
295
|
+
callers can reap orphaned wrappers that keep the virtualenv ``.exe`` locked. Returns
|
|
296
|
+
an empty list off Windows or when tasklist is unavailable.
|
|
297
|
+
"""
|
|
298
|
+
if sys.platform != "win32":
|
|
299
|
+
return []
|
|
300
|
+
pids: list[int] = []
|
|
301
|
+
seen: set[int] = set()
|
|
302
|
+
exe = _resolve("tasklist")
|
|
303
|
+
if not exe:
|
|
304
|
+
return []
|
|
305
|
+
for image in sorted(_COLLAB_LAUNCHER_IMAGE_NAMES):
|
|
306
|
+
out = _run_platform(
|
|
307
|
+
[exe, "/FI", f"IMAGENAME eq {image}", "/FO", "CSV", "/NH"],
|
|
308
|
+
timeout=30.0,
|
|
309
|
+
)
|
|
310
|
+
for line in out.splitlines():
|
|
311
|
+
line = line.strip()
|
|
312
|
+
if not line:
|
|
313
|
+
continue
|
|
314
|
+
parts = line.strip().strip('"').split('","')
|
|
315
|
+
if len(parts) >= 2:
|
|
316
|
+
try:
|
|
317
|
+
pid = int(parts[1])
|
|
318
|
+
if pid not in seen:
|
|
319
|
+
seen.add(pid)
|
|
320
|
+
pids.append(pid)
|
|
321
|
+
except (ValueError, IndexError):
|
|
322
|
+
continue
|
|
323
|
+
return pids
|
|
324
|
+
|
|
325
|
+
|
|
286
326
|
def iter_tasklist_python_pids() -> list[int]:
|
|
287
327
|
"""Collect PIDs from tasklist for known Python image names."""
|
|
288
328
|
pids: list[int] = []
|
|
@@ -22,6 +22,15 @@ logger = logging.getLogger("collab.safe_subprocess")
|
|
|
22
22
|
DEFAULT_CAPTURE_TIMEOUT_S = 30.0
|
|
23
23
|
DEFAULT_RUN_TIMEOUT_S = 60.0
|
|
24
24
|
|
|
25
|
+
# CREATE_NO_WINDOW — hide console for background git probes on Windows.
|
|
26
|
+
_WIN_CREATE_NO_WINDOW = 0x08000000
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _host_supports_creationflags() -> bool:
|
|
30
|
+
"""True only on a real Windows host (not ``sys.platform`` test doubles)."""
|
|
31
|
+
return os.name == "nt"
|
|
32
|
+
|
|
33
|
+
|
|
25
34
|
# Git subcommands used by lock_client / live_locks_watcher.
|
|
26
35
|
_ALLOWED_GIT_SUBCOMMANDS = frozenset(
|
|
27
36
|
{
|
|
@@ -229,8 +238,8 @@ def capture(
|
|
|
229
238
|
kwargs["env"] = dict(env)
|
|
230
239
|
if text:
|
|
231
240
|
kwargs["text"] = True
|
|
232
|
-
if
|
|
233
|
-
kwargs["creationflags"] =
|
|
241
|
+
if _host_supports_creationflags():
|
|
242
|
+
kwargs["creationflags"] = _WIN_CREATE_NO_WINDOW
|
|
234
243
|
try:
|
|
235
244
|
out = sp.check_output(list(safe_argv), **kwargs)
|
|
236
245
|
return CaptureResult(
|
|
@@ -271,8 +280,8 @@ def run(
|
|
|
271
280
|
kwargs: dict[str, Any] = {"cwd": cwd, "timeout": timeout}
|
|
272
281
|
if capture_output:
|
|
273
282
|
kwargs["capture_output"] = True
|
|
274
|
-
if
|
|
275
|
-
kwargs["creationflags"] =
|
|
283
|
+
if _host_supports_creationflags():
|
|
284
|
+
kwargs["creationflags"] = _WIN_CREATE_NO_WINDOW
|
|
276
285
|
try:
|
|
277
286
|
completed = sp.run(list(safe_argv), **kwargs)
|
|
278
287
|
return RunResult(
|
|
@@ -301,7 +310,7 @@ def spawn_background(
|
|
|
301
310
|
"cwd": cwd,
|
|
302
311
|
"close_fds": True,
|
|
303
312
|
}
|
|
304
|
-
if
|
|
313
|
+
if _host_supports_creationflags():
|
|
305
314
|
popen_kwargs["creationflags"] = creationflags
|
|
306
315
|
else:
|
|
307
316
|
popen_kwargs["start_new_session"] = start_new_session
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "collab-runtime"
|
|
7
|
-
version = "0.4.
|
|
7
|
+
version = "0.4.2"
|
|
8
8
|
description = "Collaborative file locking runtime"
|
|
9
9
|
readme = "docs/pypi/README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -40,7 +40,10 @@ where = ["."]
|
|
|
40
40
|
include = ["collab*"]
|
|
41
41
|
|
|
42
42
|
[tool.setuptools.package-data]
|
|
43
|
-
|
|
43
|
+
# Ship every dashboard static asset (HTML/JS/CSS) recursively so a wheel can never
|
|
44
|
+
# be missing a file referenced by index.html. A recursive glob means new assets are
|
|
45
|
+
# packaged automatically without re-listing each filename.
|
|
46
|
+
collab = ["dashboard/**"]
|
|
44
47
|
|
|
45
48
|
[tool.black]
|
|
46
49
|
line-length = 88
|
|
@@ -1,250 +0,0 @@
|
|
|
1
|
-
"""Local HTTP server for the collaborative dashboard static assets.
|
|
2
|
-
|
|
3
|
-
The dashboard HTML references sibling static files (e.g. ``dashboard-format.js``). The
|
|
4
|
-
injected config HTML must therefore be written *inside* ``collab/dashboard/`` and served
|
|
5
|
-
from that directory — not from a lone temp file in ``/tmp``.
|
|
6
|
-
"""
|
|
7
|
-
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import atexit
|
|
11
|
-
import http.server
|
|
12
|
-
import json
|
|
13
|
-
import logging
|
|
14
|
-
import os
|
|
15
|
-
import tempfile
|
|
16
|
-
import threading
|
|
17
|
-
import time
|
|
18
|
-
import urllib.parse
|
|
19
|
-
from typing import Any, Callable, Optional, Tuple
|
|
20
|
-
|
|
21
|
-
logger = logging.getLogger(__name__)
|
|
22
|
-
|
|
23
|
-
DASHBOARD_TEMP_PREFIX = ".collab-dashboard-"
|
|
24
|
-
RUNTIME_CONFIG_PATH = "/collab-runtime-config.json"
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def load_runtime_supabase_config(project_root: str) -> dict[str, Any]:
|
|
28
|
-
"""Read ``.env`` from *project_root* and return dashboard Supabase settings.
|
|
29
|
-
|
|
30
|
-
The local dashboard fetches this on each sync so credential changes take effect
|
|
31
|
-
without restarting the watcher or reopening a stale browser tab. The project
|
|
32
|
-
``.env`` is read directly (not via ``load_dotenv``) so polling never mutates the
|
|
33
|
-
running watcher's process environment.
|
|
34
|
-
"""
|
|
35
|
-
from dotenv import dotenv_values
|
|
36
|
-
|
|
37
|
-
env_path = os.path.join(project_root, ".env")
|
|
38
|
-
file_vals: dict[str, Any] = {}
|
|
39
|
-
if os.path.isfile(env_path):
|
|
40
|
-
try:
|
|
41
|
-
file_vals = dict(dotenv_values(env_path))
|
|
42
|
-
except OSError as exc:
|
|
43
|
-
logger.debug("Could not read %s for runtime config: %s", env_path, exc)
|
|
44
|
-
|
|
45
|
-
def pick(name: str) -> Optional[str]:
|
|
46
|
-
val = file_vals.get(name)
|
|
47
|
-
if not val:
|
|
48
|
-
val = os.getenv(name)
|
|
49
|
-
return val
|
|
50
|
-
|
|
51
|
-
url = pick("SUPABASE_URL") or ""
|
|
52
|
-
anon = pick("SUPABASE_ANON_KEY") or ""
|
|
53
|
-
service = pick("SUPABASE_SERVICE_ROLE_KEY") or None
|
|
54
|
-
user = (
|
|
55
|
-
pick("COLLAB_DEVELOPER_ID")
|
|
56
|
-
or pick("DEVELOPER_ID")
|
|
57
|
-
or os.getenv("USERNAME")
|
|
58
|
-
or os.getenv("USER")
|
|
59
|
-
or ""
|
|
60
|
-
)
|
|
61
|
-
from . import agent_identity
|
|
62
|
-
|
|
63
|
-
state_override = os.getenv("COLLAB_STATE_DIR", "").strip()
|
|
64
|
-
state_dir = state_override or os.path.join(project_root, ".collab")
|
|
65
|
-
agent_id = agent_identity.resolve_agent_id(state_dir)
|
|
66
|
-
agent_label = agent_identity.resolve_agent_label()
|
|
67
|
-
return {
|
|
68
|
-
"url": url,
|
|
69
|
-
"anonKey": anon,
|
|
70
|
-
"serviceKey": service,
|
|
71
|
-
"user": user,
|
|
72
|
-
"agentId": agent_id,
|
|
73
|
-
"agentLabel": agent_label,
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def create_dashboard_handler(project_root: str, directory: str) -> type:
|
|
78
|
-
"""Build a request handler that serves static assets and live runtime config."""
|
|
79
|
-
|
|
80
|
-
class DashboardHandler(http.server.SimpleHTTPRequestHandler):
|
|
81
|
-
"""Serve dashboard static files without per-request stderr logging."""
|
|
82
|
-
|
|
83
|
-
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
84
|
-
super().__init__(*args, directory=directory, **kwargs)
|
|
85
|
-
|
|
86
|
-
def log_message(self, format: str, *args: Any) -> None:
|
|
87
|
-
"""Suppress default SimpleHTTPRequestHandler request log lines."""
|
|
88
|
-
|
|
89
|
-
def do_GET(self) -> None:
|
|
90
|
-
parsed = urllib.parse.urlparse(self.path)
|
|
91
|
-
if parsed.path == RUNTIME_CONFIG_PATH:
|
|
92
|
-
self._serve_runtime_config()
|
|
93
|
-
return
|
|
94
|
-
super().do_GET()
|
|
95
|
-
|
|
96
|
-
def _serve_runtime_config(self) -> None:
|
|
97
|
-
payload = load_runtime_supabase_config(project_root)
|
|
98
|
-
body = json.dumps(payload).encode("utf-8")
|
|
99
|
-
self.send_response(200)
|
|
100
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
101
|
-
self.send_header("Content-Length", str(len(body)))
|
|
102
|
-
self.send_header("Cache-Control", "no-store")
|
|
103
|
-
self.end_headers()
|
|
104
|
-
self.wfile.write(body)
|
|
105
|
-
|
|
106
|
-
return DashboardHandler
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def dashboard_directory(resource_root: str) -> str:
|
|
110
|
-
"""Return the path to packaged dashboard static assets."""
|
|
111
|
-
return os.path.join(resource_root, "dashboard")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
def read_dashboard_template(resource_root: str) -> Optional[str]:
|
|
115
|
-
"""Read ``index.html`` from the dashboard package directory."""
|
|
116
|
-
html_path = os.path.join(dashboard_directory(resource_root), "index.html")
|
|
117
|
-
if not os.path.exists(html_path):
|
|
118
|
-
logger.error("Dashboard file not found at %s", html_path)
|
|
119
|
-
return None
|
|
120
|
-
try:
|
|
121
|
-
with open(html_path, "r", encoding="utf-8") as fh:
|
|
122
|
-
return fh.read()
|
|
123
|
-
except OSError as exc:
|
|
124
|
-
logger.error("Error reading dashboard template: %s", exc)
|
|
125
|
-
return None
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def write_injected_dashboard_html(
|
|
129
|
-
resource_root: str, injected: dict[str, Any]
|
|
130
|
-
) -> Optional[str]:
|
|
131
|
-
"""Write config-injected HTML next to static assets; return path or None."""
|
|
132
|
-
content = read_dashboard_template(resource_root)
|
|
133
|
-
if content is None:
|
|
134
|
-
return None
|
|
135
|
-
|
|
136
|
-
dash_dir = dashboard_directory(resource_root)
|
|
137
|
-
inject_script = (
|
|
138
|
-
f"<script>window.__SUPABASE_CONFIG__ = {json.dumps(injected)};</script>\n"
|
|
139
|
-
)
|
|
140
|
-
try:
|
|
141
|
-
tmp = tempfile.NamedTemporaryFile(
|
|
142
|
-
mode="w",
|
|
143
|
-
delete=False,
|
|
144
|
-
suffix=".html",
|
|
145
|
-
prefix=DASHBOARD_TEMP_PREFIX,
|
|
146
|
-
dir=dash_dir,
|
|
147
|
-
encoding="utf-8",
|
|
148
|
-
)
|
|
149
|
-
tmp.write(inject_script)
|
|
150
|
-
tmp.write(content)
|
|
151
|
-
tmp.flush()
|
|
152
|
-
tmp.close()
|
|
153
|
-
return tmp.name
|
|
154
|
-
except OSError as exc:
|
|
155
|
-
logger.error("Error creating temp dashboard file: %s", exc)
|
|
156
|
-
return None
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
def _register_temp_html_cleanup(html_path: str) -> None:
|
|
160
|
-
"""Remove generated dashboard HTML on process exit."""
|
|
161
|
-
|
|
162
|
-
def _unlink() -> None:
|
|
163
|
-
try:
|
|
164
|
-
os.unlink(html_path)
|
|
165
|
-
except OSError:
|
|
166
|
-
pass
|
|
167
|
-
|
|
168
|
-
atexit.register(_unlink)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
def start_dashboard_http_server(
|
|
172
|
-
resource_root: str,
|
|
173
|
-
injected_html_path: str,
|
|
174
|
-
*,
|
|
175
|
-
project_root: Optional[str] = None,
|
|
176
|
-
log_error: Callable[[str, Any], None] = logger.error,
|
|
177
|
-
log_warning: Callable[[str, Any], None] = logger.warning,
|
|
178
|
-
) -> Optional[str]:
|
|
179
|
-
"""Serve ``collab/dashboard`` and return the URL to the injected HTML file."""
|
|
180
|
-
dash_dir = dashboard_directory(resource_root)
|
|
181
|
-
filename = os.path.basename(injected_html_path)
|
|
182
|
-
env_root = project_root or resource_root
|
|
183
|
-
|
|
184
|
-
try:
|
|
185
|
-
handler_cls = create_dashboard_handler(env_root, dash_dir)
|
|
186
|
-
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
|
187
|
-
port = server.server_address[1]
|
|
188
|
-
|
|
189
|
-
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
190
|
-
thread.start()
|
|
191
|
-
|
|
192
|
-
def _safe_shutdown() -> None:
|
|
193
|
-
try:
|
|
194
|
-
server.shutdown()
|
|
195
|
-
except BaseException:
|
|
196
|
-
pass
|
|
197
|
-
close = getattr(server, "server_close", None)
|
|
198
|
-
if callable(close):
|
|
199
|
-
try:
|
|
200
|
-
close()
|
|
201
|
-
except OSError:
|
|
202
|
-
pass
|
|
203
|
-
|
|
204
|
-
atexit.register(_safe_shutdown)
|
|
205
|
-
_register_temp_html_cleanup(injected_html_path)
|
|
206
|
-
|
|
207
|
-
url = f"http://127.0.0.1:{port}/{filename}"
|
|
208
|
-
|
|
209
|
-
import socket
|
|
210
|
-
|
|
211
|
-
for _ in range(20):
|
|
212
|
-
try:
|
|
213
|
-
with socket.create_connection(("127.0.0.1", port), timeout=0.3):
|
|
214
|
-
break
|
|
215
|
-
except OSError:
|
|
216
|
-
time.sleep(0.05)
|
|
217
|
-
|
|
218
|
-
return url
|
|
219
|
-
except OSError as exc:
|
|
220
|
-
log_error("Failed to start local dashboard server: %s", exc)
|
|
221
|
-
try:
|
|
222
|
-
os.unlink(injected_html_path)
|
|
223
|
-
except OSError as cleanup_exc:
|
|
224
|
-
log_warning("Dashboard temp-file cleanup failed: %s", cleanup_exc)
|
|
225
|
-
return None
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
def prepare_dashboard_server(
|
|
229
|
-
resource_root: str,
|
|
230
|
-
injected: dict[str, Any],
|
|
231
|
-
*,
|
|
232
|
-
project_root: Optional[str] = None,
|
|
233
|
-
log_error: Callable[[str, Any], None] = logger.error,
|
|
234
|
-
log_warning: Callable[[str, Any], None] = logger.warning,
|
|
235
|
-
) -> Tuple[Optional[str], Optional[str]]:
|
|
236
|
-
"""Write injected HTML, start server from dashboard dir; return (url, path)."""
|
|
237
|
-
html_path = write_injected_dashboard_html(resource_root, injected)
|
|
238
|
-
if not html_path:
|
|
239
|
-
return None, None
|
|
240
|
-
|
|
241
|
-
url = start_dashboard_http_server(
|
|
242
|
-
resource_root,
|
|
243
|
-
html_path,
|
|
244
|
-
project_root=project_root,
|
|
245
|
-
log_error=log_error,
|
|
246
|
-
log_warning=log_warning,
|
|
247
|
-
)
|
|
248
|
-
if not url:
|
|
249
|
-
return None, None
|
|
250
|
-
return url, html_path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|