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.
- msdev/__init__.py +3 -0
- msdev/cli.py +1460 -0
- msdev/core/__init__.py +1 -0
- msdev/core/config.py +193 -0
- msdev/core/guides.py +100 -0
- msdev/core/inventory.py +1390 -0
- msdev/core/limits.py +44 -0
- msdev/core/resources.py +300 -0
- msdev/core/services/__init__.py +111 -0
- msdev/core/services/context.py +61 -0
- msdev/core/services/environment.py +84 -0
- msdev/core/services/execution.py +136 -0
- msdev/core/services/model.py +1085 -0
- msdev/core/services/node.py +210 -0
- msdev/core/services/npu.py +116 -0
- msdev/core/services/workspace.py +567 -0
- msdev/core/transport.py +1225 -0
- msdev/core/workspace/__init__.py +36 -0
- msdev/core/workspace/access.py +1216 -0
- msdev/core/workspace/client.py +274 -0
- msdev/core/workspace/paths.py +45 -0
- msdev/core/workspace/registry.py +151 -0
- msdev/daemon.py +1322 -0
- msdev/session/__init__.py +13 -0
- msdev/session/export.py +190 -0
- msdev/session/log.py +269 -0
- msdev-0.9.0.dist-info/METADATA +295 -0
- msdev-0.9.0.dist-info/RECORD +31 -0
- msdev-0.9.0.dist-info/WHEEL +5 -0
- msdev-0.9.0.dist-info/entry_points.txt +3 -0
- msdev-0.9.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
+
]
|
msdev/session/export.py
ADDED
|
@@ -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
|