msdev 0.9.0__py3-none-any.whl

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.
@@ -0,0 +1,13 @@
1
+ """Safe logging and export of explicit msdev CLI sessions."""
2
+
3
+ from .export import iter_events, list_sessions, render_markdown
4
+ from .log import CliOperationLogger, CliSession, begin_session
5
+
6
+ __all__ = [
7
+ "CliOperationLogger",
8
+ "CliSession",
9
+ "begin_session",
10
+ "iter_events",
11
+ "list_sessions",
12
+ "render_markdown",
13
+ ]
@@ -0,0 +1,190 @@
1
+ """Discovery and Markdown export for CLI operation sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shlex
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any, Iterable, Iterator, Mapping, Sequence
12
+
13
+ from .log import (
14
+ MAX_LOG_LINE_BYTES,
15
+ SCHEMA_VERSION,
16
+ session_directory,
17
+ session_path,
18
+ )
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SessionInfo:
23
+ name: str
24
+ size: int
25
+ mtime_ns: int
26
+ path: Path
27
+
28
+
29
+ def session_log_location(
30
+ environ: Mapping[str, str] | None = None,
31
+ ) -> Path:
32
+ return session_directory(environ)
33
+
34
+
35
+ def list_sessions(state_dir: str | os.PathLike[str]) -> list[SessionInfo]:
36
+ directory = Path(state_dir)
37
+ if not directory.is_dir():
38
+ return []
39
+ result = []
40
+ for path in directory.glob("*.jsonl"):
41
+ try:
42
+ info = path.stat()
43
+ except OSError:
44
+ continue
45
+ if path.is_symlink() or not path.is_file():
46
+ continue
47
+ result.append(SessionInfo(path.stem, info.st_size, info.st_mtime_ns, path))
48
+ return sorted(result, key=lambda item: (item.mtime_ns, item.name), reverse=True)
49
+
50
+
51
+ def resolve_session_sources(
52
+ *,
53
+ latest: bool,
54
+ inputs: Sequence[Path],
55
+ session_id: str | None = None,
56
+ state_dir: Path | None = None,
57
+ ) -> list[Path]:
58
+ if inputs:
59
+ return [Path(path).expanduser().resolve(strict=True) for path in inputs]
60
+ if latest:
61
+ sessions = list_sessions(state_dir or session_log_location())
62
+ if not sessions:
63
+ raise ValueError("no CLI session logs found")
64
+ return [sessions[0].path]
65
+ if session_id:
66
+ path = session_path(session_id)
67
+ if not path.is_file():
68
+ raise ValueError(f"unknown msdev session: {session_id}")
69
+ return [path]
70
+ raise ValueError("select --input, --latest, or --session-id")
71
+
72
+
73
+ def reject_output_alias(output: Path, sources: Sequence[Path]) -> None:
74
+ candidate = output.expanduser().resolve(strict=False)
75
+ if any(candidate == source.resolve(strict=True) for source in sources):
76
+ raise ValueError("output must not overwrite an input session log")
77
+
78
+
79
+ def write_markdown_atomic(output: Path, chunks: Iterable[str]) -> None:
80
+ destination = output.expanduser()
81
+ destination.parent.mkdir(parents=True, exist_ok=True)
82
+ descriptor, temporary = tempfile.mkstemp(
83
+ prefix=f".{destination.name}.",
84
+ dir=destination.parent,
85
+ )
86
+ try:
87
+ with os.fdopen(descriptor, "w", encoding="utf-8") as handle:
88
+ for chunk in chunks:
89
+ handle.write(chunk)
90
+ handle.flush()
91
+ os.fsync(handle.fileno())
92
+ os.replace(temporary, destination)
93
+ except BaseException:
94
+ try:
95
+ os.unlink(temporary)
96
+ except FileNotFoundError:
97
+ pass
98
+ raise
99
+
100
+
101
+ def _valid_event(value: Any) -> bool:
102
+ return (
103
+ isinstance(value, dict)
104
+ and value.get("schema_version") == SCHEMA_VERSION
105
+ and isinstance(value.get("session_id"), str)
106
+ and isinstance(value.get("operation_id"), str)
107
+ and isinstance(value.get("command"), str)
108
+ and isinstance(value.get("argv"), list)
109
+ and all(isinstance(item, str) for item in value["argv"])
110
+ and isinstance(value.get("exit_code"), int)
111
+ and not isinstance(value.get("exit_code"), bool)
112
+ )
113
+
114
+
115
+ def iter_events(sources: Sequence[Path]) -> Iterator[dict[str, Any]]:
116
+ for source in sources:
117
+ with source.open("rb") as handle:
118
+ for raw in handle:
119
+ if len(raw) > MAX_LOG_LINE_BYTES:
120
+ continue
121
+ try:
122
+ value = json.loads(raw)
123
+ except (UnicodeDecodeError, json.JSONDecodeError):
124
+ continue
125
+ if _valid_event(value):
126
+ yield value
127
+
128
+
129
+ def _command(event: dict[str, Any]) -> str:
130
+ return "msdev " + shlex.join(event["argv"])
131
+
132
+
133
+ def _entry(event: dict[str, Any], detail: str) -> Iterator[str]:
134
+ intent = event.get("intent") if isinstance(event.get("intent"), dict) else {}
135
+ summary = intent.get("summary") or event["command"]
136
+ yield f"### {summary}\n"
137
+ preview = event.get("preview")
138
+ if isinstance(preview, str):
139
+ yield f"{preview}\n\n"
140
+ yield "```bash\n"
141
+ yield _command(event) + "\n"
142
+ yield "```\n"
143
+ yield (
144
+ f"Exit: `{event['exit_code']}`; elapsed: "
145
+ f"`{event.get('elapsed_ms', 0)} ms`.\n\n"
146
+ )
147
+ if detail == "full":
148
+ yield "```json\n"
149
+ yield json.dumps(event, ensure_ascii=False, indent=2, sort_keys=True)
150
+ yield "\n```\n\n"
151
+
152
+
153
+ def render_markdown(
154
+ events: Iterable[dict[str, Any]],
155
+ detail: str,
156
+ ) -> Iterator[str]:
157
+ if detail not in {"brief", "normal", "full"}:
158
+ raise ValueError(f"unsupported detail: {detail}")
159
+ groups: dict[str, list[dict[str, Any]]] = {
160
+ "execution": [],
161
+ "verification": [],
162
+ "other": [],
163
+ }
164
+ for event in events:
165
+ intent = event.get("intent")
166
+ kind = intent.get("kind") if isinstance(intent, dict) else None
167
+ if kind == "execution":
168
+ groups["execution"].append(event)
169
+ elif kind == "verification":
170
+ groups["verification"].append(event)
171
+ else:
172
+ groups["other"].append(event)
173
+
174
+ yield "# msdev CLI session runbook\n\n"
175
+ sections = (
176
+ ("Core reproduction steps", groups["execution"]),
177
+ ("Optional verification", groups["verification"]),
178
+ ("Diagnostics and exploration", groups["other"]),
179
+ )
180
+ for title, values in sections:
181
+ if not values:
182
+ continue
183
+ yield f"## {title}\n\n"
184
+ if detail == "brief":
185
+ for event in values:
186
+ yield f"- `{_command(event)}` (exit {event['exit_code']})\n"
187
+ yield "\n"
188
+ else:
189
+ for event in values:
190
+ yield from _entry(event, detail)
msdev/session/log.py ADDED
@@ -0,0 +1,269 @@
1
+ """Secure, process-safe CLI operation session logging."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import fcntl
7
+ import json
8
+ import os
9
+ import re
10
+ import shlex
11
+ import time
12
+ import urllib.parse
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any, Mapping, Sequence
17
+
18
+
19
+ SCHEMA_VERSION = 1
20
+ MAX_LOG_LINE_BYTES = 16 * 1024
21
+ INTENT_KINDS = frozenset(
22
+ {"execution", "verification", "diagnostic", "exploration"}
23
+ )
24
+ _SESSION_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
25
+ _SENSITIVE_RE = re.compile(
26
+ r"(?:token|password|passwd|secret|api[_-]?key|authorization|credential)",
27
+ re.IGNORECASE,
28
+ )
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class CliSession:
33
+ session_id: str
34
+ path: Path
35
+
36
+
37
+ def session_directory(
38
+ environ: Mapping[str, str] | None = None,
39
+ ) -> Path:
40
+ env = os.environ if environ is None else environ
41
+ state_home = env.get("XDG_STATE_HOME")
42
+ base = (
43
+ Path(state_home).expanduser()
44
+ if state_home
45
+ else Path(env.get("HOME", "~")).expanduser() / ".local" / "state"
46
+ )
47
+ return base / "msdev" / "sessions"
48
+
49
+
50
+ def validate_session_id(value: str) -> str:
51
+ if not isinstance(value, str) or not _SESSION_ID_RE.fullmatch(value):
52
+ raise ValueError(
53
+ "session_id must use 1-128 ASCII letters, digits, '.', '_' or '-'"
54
+ )
55
+ return value
56
+
57
+
58
+ def session_path(
59
+ session_id: str,
60
+ *,
61
+ environ: Mapping[str, str] | None = None,
62
+ ) -> Path:
63
+ return session_directory(environ) / f"{validate_session_id(session_id)}.jsonl"
64
+
65
+
66
+ def _slug(value: str | None) -> str:
67
+ normalized = re.sub(r"[^A-Za-z0-9._-]+", "-", (value or "session")).strip(
68
+ ".-"
69
+ )
70
+ return (normalized or "session")[:48]
71
+
72
+
73
+ def begin_session(
74
+ name: str | None = None,
75
+ *,
76
+ environ: Mapping[str, str] | None = None,
77
+ ) -> CliSession:
78
+ timestamp = dt.datetime.now(dt.timezone.utc).strftime("%Y%m%dT%H%M%SZ")
79
+ session_id = f"{timestamp}-{_slug(name)}-{uuid.uuid4().hex[:8]}"
80
+ path = session_path(session_id, environ=environ)
81
+ path.parent.mkdir(parents=True, mode=0o700, exist_ok=True)
82
+ os.chmod(path.parent, 0o700)
83
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
84
+ if hasattr(os, "O_NOFOLLOW"):
85
+ flags |= os.O_NOFOLLOW
86
+ descriptor = os.open(path, flags, 0o600)
87
+ os.close(descriptor)
88
+ return CliSession(session_id, path)
89
+
90
+
91
+ def _clip(value: Any, maximum: int) -> str:
92
+ raw = str(value).encode("utf-8", errors="replace")[:maximum]
93
+ return raw.decode("utf-8", errors="ignore")
94
+
95
+
96
+ def _sanitize_argument(value: str) -> str:
97
+ if "=" in value:
98
+ key, separator, item = value.partition("=")
99
+ if separator and _SENSITIVE_RE.search(key):
100
+ return f"{key}=<redacted>"
101
+ if key and key.upper() == key and len(key) <= 128:
102
+ return f"{key}=<redacted>"
103
+ if "://" in value:
104
+ try:
105
+ parsed = urllib.parse.urlsplit(value)
106
+ hostname = parsed.hostname or ""
107
+ if parsed.port is not None:
108
+ hostname = f"{hostname}:{parsed.port}"
109
+ return urllib.parse.urlunsplit(
110
+ (parsed.scheme, hostname, parsed.path, "", "")
111
+ )
112
+ except ValueError:
113
+ return "<redacted-url>"
114
+ return _clip(value, 1024)
115
+
116
+
117
+ def sanitize_argv(argv: Sequence[str]) -> list[str]:
118
+ retained: list[str] = []
119
+ redact_next = False
120
+ for raw in list(argv)[:256]:
121
+ value = str(raw)
122
+ if redact_next:
123
+ if "=" in value:
124
+ key = value.partition("=")[0]
125
+ retained.append(f"{key}=<redacted>")
126
+ else:
127
+ retained.append("<redacted>")
128
+ redact_next = False
129
+ continue
130
+ retained.append(_sanitize_argument(value))
131
+ flag = value.lstrip("-").replace("-", "_")
132
+ if value in {"--env-var", "--content"} or _SENSITIVE_RE.search(flag):
133
+ redact_next = True
134
+ return retained
135
+
136
+
137
+ def sanitize_intent(value: Mapping[str, Any] | None) -> dict[str, str] | None:
138
+ if not value:
139
+ return None
140
+ kind = value.get("kind")
141
+ if kind not in INTENT_KINDS:
142
+ raise ValueError(f"invalid intent kind: {kind!r}")
143
+ result = {"kind": str(kind)}
144
+ for key, limit in (("summary", 256), ("phase", 64), ("step_id", 64)):
145
+ item = value.get(key)
146
+ if item is not None:
147
+ if not isinstance(item, str) or not item.strip():
148
+ raise ValueError(f"intent {key} must be a non-empty string")
149
+ text = _clip(item.strip(), limit)
150
+ text = re.sub(
151
+ r"(?i)(token|password|secret|api[_-]?key)\s*=\s*\S+",
152
+ r"\1=<redacted>",
153
+ text,
154
+ )
155
+ result[key] = text
156
+ return result
157
+
158
+
159
+ def _preview(
160
+ command: str,
161
+ argv: list[str],
162
+ node: str | None,
163
+ environment: str | None,
164
+ workspace: str | None,
165
+ intent: dict[str, str] | None,
166
+ ) -> str:
167
+ subject = environment or node or workspace or "-"
168
+ summary = intent.get("summary", "未提供意图") if intent else "未提供意图"
169
+ kind = intent["kind"] if intent else "unspecified"
170
+ detail = shlex.join(argv)
171
+ return _clip(
172
+ f"【{command} · {subject}】【{summary}】【{kind}】 {detail}",
173
+ 1024,
174
+ )
175
+
176
+
177
+ class CliOperationLogger:
178
+ def __init__(
179
+ self,
180
+ session_id: str,
181
+ *,
182
+ environ: Mapping[str, str] | None = None,
183
+ ):
184
+ self.session_id = validate_session_id(session_id)
185
+ self.path = session_path(session_id, environ=environ)
186
+ if not self.path.is_file():
187
+ raise FileNotFoundError(f"unknown msdev session: {session_id}")
188
+
189
+ def record(
190
+ self,
191
+ *,
192
+ argv: Sequence[str],
193
+ command: str,
194
+ exit_code: int,
195
+ elapsed_ms: float,
196
+ node: str | None = None,
197
+ environment: str | None = None,
198
+ workspace: str | None = None,
199
+ intent: Mapping[str, Any] | None = None,
200
+ ) -> str:
201
+ operation_id = uuid.uuid4().hex
202
+ safe_argv = sanitize_argv(argv)
203
+ safe_intent = sanitize_intent(intent)
204
+ event: dict[str, Any] = {
205
+ "schema_version": SCHEMA_VERSION,
206
+ "timestamp": dt.datetime.now(dt.timezone.utc)
207
+ .isoformat(timespec="milliseconds")
208
+ .replace("+00:00", "Z"),
209
+ "session_id": self.session_id,
210
+ "operation_id": operation_id,
211
+ "command": _clip(command, 128),
212
+ "argv": safe_argv,
213
+ "preview": _preview(
214
+ command,
215
+ safe_argv,
216
+ node,
217
+ environment,
218
+ workspace,
219
+ safe_intent,
220
+ ),
221
+ "exit_code": int(exit_code),
222
+ "elapsed_ms": max(0.0, round(float(elapsed_ms), 3)),
223
+ }
224
+ if node:
225
+ event["node"] = _clip(node, 128)
226
+ if environment:
227
+ event["environment"] = _clip(environment, 128)
228
+ if workspace:
229
+ event["workspace"] = _clip(workspace, 128)
230
+ if safe_intent:
231
+ event["intent"] = safe_intent
232
+ line = (
233
+ json.dumps(event, ensure_ascii=False, separators=(",", ":")) + "\n"
234
+ ).encode("utf-8")
235
+ if len(line) > MAX_LOG_LINE_BYTES:
236
+ event["truncated"] = True
237
+ for count in (16, 8, 4, 0):
238
+ event["argv"] = safe_argv[:count]
239
+ line = (
240
+ json.dumps(
241
+ event,
242
+ ensure_ascii=False,
243
+ separators=(",", ":"),
244
+ )
245
+ + "\n"
246
+ ).encode("utf-8")
247
+ if len(line) <= MAX_LOG_LINE_BYTES:
248
+ break
249
+ else:
250
+ raise ValueError("CLI operation log event exceeds byte budget")
251
+ flags = os.O_WRONLY | os.O_APPEND
252
+ if hasattr(os, "O_NOFOLLOW"):
253
+ flags |= os.O_NOFOLLOW
254
+ descriptor = os.open(self.path, flags)
255
+ try:
256
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
257
+ offset = 0
258
+ while offset < len(line):
259
+ offset += os.write(descriptor, line[offset:])
260
+ finally:
261
+ try:
262
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
263
+ finally:
264
+ os.close(descriptor)
265
+ return operation_id
266
+
267
+
268
+ def monotonic_ms(started: float) -> float:
269
+ return (time.monotonic() - started) * 1000.0