pal-found-cli 0.1.2__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.
- pal_found_cli/__init__.py +1 -0
- pal_found_cli/_version.py +24 -0
- pal_found_cli/admin/__init__.py +2 -0
- pal_found_cli/admin/scripts/__init__.py +2 -0
- pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
- pal_found_cli/aip_agents/__init__.py +1 -0
- pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
- pal_found_cli/aip_agents/scripts/__init__.py +1 -0
- pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
- pal_found_cli/audit/__init__.py +1 -0
- pal_found_cli/audit/metadata-allow-list.md +4 -0
- pal_found_cli/audit/scripts/__init__.py +1 -0
- pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
- pal_found_cli/checkpoints/__init__.py +9 -0
- pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
- pal_found_cli/checkpoints/scripts/__init__.py +1 -0
- pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
- pal_found_cli/common/__init__.py +56 -0
- pal_found_cli/common/access_control_guard.py +591 -0
- pal_found_cli/common/async_client_factory.py +161 -0
- pal_found_cli/common/auth_provider.py +82 -0
- pal_found_cli/common/binary_download_handler.py +288 -0
- pal_found_cli/common/config_loader.py +388 -0
- pal_found_cli/common/error_serializer.py +412 -0
- pal_found_cli/common/log_setup.py +241 -0
- pal_found_cli/common/metadata-allow-list.md +169 -0
- pal_found_cli/common/output_formatter.py +267 -0
- pal_found_cli/common/pagination_helper.py +212 -0
- pal_found_cli/common/retry.py +617 -0
- pal_found_cli/common/sdk_error_utils.py +112 -0
- pal_found_cli/common/session_manager.py +461 -0
- pal_found_cli/common/tracing_provider.py +107 -0
- pal_found_cli/connectivity/__init__.py +9 -0
- pal_found_cli/connectivity/metadata-allow-list.md +24 -0
- pal_found_cli/connectivity/scripts/__init__.py +1 -0
- pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
- pal_found_cli/data_health/__init__.py +9 -0
- pal_found_cli/data_health/metadata-allow-list.md +10 -0
- pal_found_cli/data_health/scripts/__init__.py +1 -0
- pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
- pal_found_cli/datasets/__init__.py +1 -0
- pal_found_cli/datasets/scripts/__init__.py +1 -0
- pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
- pal_found_cli/filesystem/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/__init__.py +2 -0
- pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
- pal_found_cli/functions/__init__.py +2 -0
- pal_found_cli/functions/scripts/__init__.py +2 -0
- pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
- pal_found_cli/language_models/__init__.py +1 -0
- pal_found_cli/language_models/metadata-allow-list.md +6 -0
- pal_found_cli/language_models/scripts/__init__.py +1 -0
- pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
- pal_found_cli/media_sets/__init__.py +9 -0
- pal_found_cli/media_sets/metadata-allow-list.md +23 -0
- pal_found_cli/media_sets/scripts/__init__.py +1 -0
- pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
- pal_found_cli/models/__init__.py +5 -0
- pal_found_cli/models/metadata-allow-list.md +27 -0
- pal_found_cli/models/scripts/__init__.py +1 -0
- pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
- pal_found_cli/ontologies/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/__init__.py +1 -0
- pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
- pal_found_cli/orchestration/__init__.py +9 -0
- pal_found_cli/orchestration/metadata-allow-list.md +24 -0
- pal_found_cli/orchestration/scripts/__init__.py +1 -0
- pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
- pal_found_cli/sql_queries/__init__.py +9 -0
- pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
- pal_found_cli/sql_queries/scripts/__init__.py +1 -0
- pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
- pal_found_cli/streams/__init__.py +9 -0
- pal_found_cli/streams/metadata-allow-list.md +19 -0
- pal_found_cli/streams/scripts/__init__.py +1 -0
- pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
- pal_found_cli/third_party_applications/__init__.py +9 -0
- pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
- pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
- pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
- pal_found_cli/widgets/__init__.py +9 -0
- pal_found_cli/widgets/metadata-allow-list.md +12 -0
- pal_found_cli/widgets/scripts/__init__.py +1 -0
- pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
- pal_found_cli-0.1.2.dist-info/METADATA +74 -0
- pal_found_cli-0.1.2.dist-info/RECORD +89 -0
- pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
- pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
- pal_found_cli-0.1.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Safe classification helpers for optional Foundry SDK exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from importlib import import_module
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
_SDK_STATUS_BY_CLASS = {
|
|
9
|
+
"UnauthorizedError": 401,
|
|
10
|
+
"PermissionDeniedError": 403,
|
|
11
|
+
"NotFoundError": 404,
|
|
12
|
+
"RateLimitError": 429,
|
|
13
|
+
"ServiceUnavailable": 503,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
# Ordered so subclasses with more specific ADR behavior win before their bases.
|
|
17
|
+
_SDK_EXIT_BY_CLASS = (
|
|
18
|
+
("UnauthorizedError", 2),
|
|
19
|
+
("NotAuthenticated", 2),
|
|
20
|
+
("PermissionDeniedError", 3),
|
|
21
|
+
("ApiNotFoundError", 4),
|
|
22
|
+
("NotFoundError", 4),
|
|
23
|
+
("BadRequestError", 1),
|
|
24
|
+
("ConflictError", 1),
|
|
25
|
+
("RequestEntityTooLargeError", 1),
|
|
26
|
+
("UnprocessableEntityError", 1),
|
|
27
|
+
("EnvironmentNotConfigured", 9),
|
|
28
|
+
("TimeoutError", 5),
|
|
29
|
+
("RateLimitError", 7),
|
|
30
|
+
("ServiceUnavailable", 6),
|
|
31
|
+
("ConnectionError", 6),
|
|
32
|
+
("InternalServerError", 6),
|
|
33
|
+
("SDKInternalError", 6),
|
|
34
|
+
("SseContentTypeError", 6),
|
|
35
|
+
("SseEventDecodeError", 6),
|
|
36
|
+
("StreamConsumedError", 6),
|
|
37
|
+
("PalantirException", 6),
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _sdk_errors() -> Any | None:
|
|
42
|
+
"""Load the optional SDK error module without making SDK availability mandatory."""
|
|
43
|
+
try:
|
|
44
|
+
return import_module("foundry_sdk._errors")
|
|
45
|
+
except ImportError:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def sdk_exception_exit_code(exception: BaseException) -> int | None:
|
|
50
|
+
"""Map an installed Foundry SDK exception to the ADR-001 taxonomy."""
|
|
51
|
+
errors = _sdk_errors()
|
|
52
|
+
if errors is None:
|
|
53
|
+
return None
|
|
54
|
+
for class_name, exit_code in _SDK_EXIT_BY_CLASS:
|
|
55
|
+
error_class = getattr(errors, class_name, None)
|
|
56
|
+
if (
|
|
57
|
+
isinstance(error_class, type)
|
|
58
|
+
and issubclass(error_class, BaseException)
|
|
59
|
+
and isinstance(exception, error_class)
|
|
60
|
+
):
|
|
61
|
+
return exit_code
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def sdk_exception_exit_map() -> dict[type[BaseException], int]:
|
|
66
|
+
"""Return installed SDK exception classes and their ADR-001 exit codes."""
|
|
67
|
+
errors = _sdk_errors()
|
|
68
|
+
if errors is None:
|
|
69
|
+
return {}
|
|
70
|
+
mapping: dict[type[BaseException], int] = {}
|
|
71
|
+
for class_name, exit_code in _SDK_EXIT_BY_CLASS:
|
|
72
|
+
error_class = getattr(errors, class_name, None)
|
|
73
|
+
if isinstance(error_class, type) and issubclass(error_class, BaseException):
|
|
74
|
+
mapping[error_class] = exit_code
|
|
75
|
+
return mapping
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def is_sdk_retryable_transport(exception: BaseException) -> bool:
|
|
79
|
+
"""Return whether a native SDK timeout or connection failure is retryable."""
|
|
80
|
+
errors = _sdk_errors()
|
|
81
|
+
if errors is None:
|
|
82
|
+
return False
|
|
83
|
+
for class_name in ("TimeoutError", "ConnectionError"):
|
|
84
|
+
error_class = getattr(errors, class_name, None)
|
|
85
|
+
if (
|
|
86
|
+
isinstance(error_class, type)
|
|
87
|
+
and issubclass(error_class, BaseException)
|
|
88
|
+
and isinstance(exception, error_class)
|
|
89
|
+
):
|
|
90
|
+
return True
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def sdk_http_status(exception: BaseException) -> int | None:
|
|
95
|
+
"""Return HTTP status carried by a response or native SDK error class."""
|
|
96
|
+
response = getattr(exception, "response", None)
|
|
97
|
+
status = getattr(response, "status_code", None)
|
|
98
|
+
if isinstance(status, int):
|
|
99
|
+
return status
|
|
100
|
+
|
|
101
|
+
errors = _sdk_errors()
|
|
102
|
+
if errors is None:
|
|
103
|
+
return None
|
|
104
|
+
for class_name, class_status in _SDK_STATUS_BY_CLASS.items():
|
|
105
|
+
error_class = getattr(errors, class_name, None)
|
|
106
|
+
if (
|
|
107
|
+
isinstance(error_class, type)
|
|
108
|
+
and issubclass(error_class, BaseException)
|
|
109
|
+
and isinstance(exception, error_class)
|
|
110
|
+
):
|
|
111
|
+
return class_status
|
|
112
|
+
return None
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Atomic local session persistence with cross-process alias locking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import importlib
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
import unicodedata
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from datetime import UTC, datetime, timedelta
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Literal, Protocol, cast
|
|
18
|
+
|
|
19
|
+
from pal_found_cli.common.config_loader import ConfigLoader
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_ALIAS_PATTERN = re.compile(r"(?:[a-z0-9]|[a-z0-9][a-z0-9._-]{0,62}[a-z0-9])\Z")
|
|
24
|
+
_WINDOWS_RESERVED = {
|
|
25
|
+
"aux",
|
|
26
|
+
"clock$",
|
|
27
|
+
"con",
|
|
28
|
+
"nul",
|
|
29
|
+
"prn",
|
|
30
|
+
*(f"com{i}" for i in range(1, 10)),
|
|
31
|
+
*(f"lpt{i}" for i in range(1, 10)),
|
|
32
|
+
}
|
|
33
|
+
_VALID_STATUSES = {"active", "completed", "expired"}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class RemoteSession(Protocol):
|
|
37
|
+
"""Minimum SDK session shape required by ``SessionManager``."""
|
|
38
|
+
|
|
39
|
+
rid: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class SessionError(Exception):
|
|
43
|
+
"""Base error for local session management."""
|
|
44
|
+
|
|
45
|
+
exit_code = 1
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidSessionAliasError(SessionError, ValueError):
|
|
49
|
+
"""Raised when an alias cannot be normalized safely."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SessionAliasConflictError(SessionError):
|
|
53
|
+
"""Raised when an active session already owns an alias."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class SessionNotFoundError(SessionError, FileNotFoundError):
|
|
57
|
+
"""Raised when no local state exists for an alias."""
|
|
58
|
+
|
|
59
|
+
exit_code = 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class SessionCorruptionError(SessionError):
|
|
63
|
+
"""Raised after corrupt local state has been removed safely."""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class SessionPersistenceError(SessionError):
|
|
67
|
+
"""Raised when remote creation succeeds but local persistence fails."""
|
|
68
|
+
|
|
69
|
+
exit_code = 6
|
|
70
|
+
|
|
71
|
+
def __init__(self, message: str, *, session_id: str | None = None) -> None:
|
|
72
|
+
super().__init__(message)
|
|
73
|
+
self.diagnostic_metadata = (
|
|
74
|
+
{"session_id": session_id} if session_id is not None else {}
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class SessionState:
|
|
80
|
+
"""Persisted AIP Agents session state."""
|
|
81
|
+
|
|
82
|
+
session_id: str
|
|
83
|
+
agent_rid: str
|
|
84
|
+
session_token: str | None
|
|
85
|
+
created_at: str
|
|
86
|
+
last_used_at: str
|
|
87
|
+
status: Literal["active", "completed", "expired"]
|
|
88
|
+
tool_history: list[dict[str, Any]]
|
|
89
|
+
|
|
90
|
+
def to_dict(self) -> dict[str, Any]:
|
|
91
|
+
"""Return a JSON-serializable copy of this state."""
|
|
92
|
+
return asdict(self)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class _AliasLock:
|
|
96
|
+
"""Small platform lock wrapper backed by a persistent lock file."""
|
|
97
|
+
|
|
98
|
+
def __init__(self, path: Path) -> None:
|
|
99
|
+
self.path = path
|
|
100
|
+
self._file: Any = None
|
|
101
|
+
|
|
102
|
+
def acquire(self, *, blocking: bool) -> bool:
|
|
103
|
+
"""Acquire the lock, returning ``False`` for a nonblocking conflict."""
|
|
104
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
105
|
+
file_obj = self.path.open("a+b")
|
|
106
|
+
try:
|
|
107
|
+
if os.name == "nt":
|
|
108
|
+
msvcrt: Any = importlib.import_module("msvcrt")
|
|
109
|
+
|
|
110
|
+
file_obj.seek(0, os.SEEK_END)
|
|
111
|
+
if file_obj.tell() == 0:
|
|
112
|
+
file_obj.write(b"\0")
|
|
113
|
+
file_obj.flush()
|
|
114
|
+
file_obj.seek(0)
|
|
115
|
+
mode = msvcrt.LK_LOCK if blocking else msvcrt.LK_NBLCK
|
|
116
|
+
try:
|
|
117
|
+
msvcrt.locking(file_obj.fileno(), mode, 1)
|
|
118
|
+
except OSError:
|
|
119
|
+
file_obj.close()
|
|
120
|
+
return False
|
|
121
|
+
else:
|
|
122
|
+
fcntl: Any = importlib.import_module("fcntl")
|
|
123
|
+
|
|
124
|
+
flags = fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB)
|
|
125
|
+
try:
|
|
126
|
+
fcntl.flock(file_obj.fileno(), flags)
|
|
127
|
+
except BlockingIOError:
|
|
128
|
+
file_obj.close()
|
|
129
|
+
return False
|
|
130
|
+
except BaseException:
|
|
131
|
+
file_obj.close()
|
|
132
|
+
raise
|
|
133
|
+
self._file = file_obj
|
|
134
|
+
return True
|
|
135
|
+
|
|
136
|
+
def release(self) -> None:
|
|
137
|
+
"""Release this process's lock and close its descriptor."""
|
|
138
|
+
if self._file is None:
|
|
139
|
+
return
|
|
140
|
+
try:
|
|
141
|
+
if os.name == "nt":
|
|
142
|
+
msvcrt: Any = importlib.import_module("msvcrt")
|
|
143
|
+
|
|
144
|
+
self._file.seek(0)
|
|
145
|
+
msvcrt.locking(self._file.fileno(), msvcrt.LK_UNLCK, 1)
|
|
146
|
+
else:
|
|
147
|
+
fcntl: Any = importlib.import_module("fcntl")
|
|
148
|
+
|
|
149
|
+
fcntl.flock(self._file.fileno(), fcntl.LOCK_UN)
|
|
150
|
+
finally:
|
|
151
|
+
self._file.close()
|
|
152
|
+
self._file = None
|
|
153
|
+
|
|
154
|
+
def __enter__(self) -> _AliasLock:
|
|
155
|
+
if not self.acquire(blocking=True):
|
|
156
|
+
raise SessionError(
|
|
157
|
+
f"Could not acquire session alias lock: {self.path.name}"
|
|
158
|
+
)
|
|
159
|
+
return self
|
|
160
|
+
|
|
161
|
+
def __exit__(self, *_args: object) -> None:
|
|
162
|
+
self.release()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class SessionManager:
|
|
166
|
+
"""Persist session aliases as validated, atomic JSON records."""
|
|
167
|
+
|
|
168
|
+
def __init__(
|
|
169
|
+
self,
|
|
170
|
+
session_root: str | Path | None = None,
|
|
171
|
+
*,
|
|
172
|
+
config: ConfigLoader | None = None,
|
|
173
|
+
expiry_days: int = 7,
|
|
174
|
+
) -> None:
|
|
175
|
+
cfg = config or ConfigLoader()
|
|
176
|
+
self.session_root = Path(
|
|
177
|
+
session_root if session_root is not None else cfg.session_path
|
|
178
|
+
).expanduser()
|
|
179
|
+
if expiry_days <= 0:
|
|
180
|
+
raise ValueError("expiry_days must be positive")
|
|
181
|
+
self.expiry = timedelta(days=expiry_days)
|
|
182
|
+
self._ensure_root()
|
|
183
|
+
|
|
184
|
+
async def create(
|
|
185
|
+
self,
|
|
186
|
+
alias: str,
|
|
187
|
+
agent_rid: str,
|
|
188
|
+
create_remote: Callable[[], Awaitable[RemoteSession]],
|
|
189
|
+
delete_remote: Callable[[str], Awaitable[None]] | None = None,
|
|
190
|
+
) -> SessionState:
|
|
191
|
+
"""Create one remote session and bind it atomically to ``alias``."""
|
|
192
|
+
canonical = self.normalize_alias(alias)
|
|
193
|
+
lock = self._lock(canonical)
|
|
194
|
+
while not lock.acquire(blocking=False):
|
|
195
|
+
await asyncio.sleep(0.05)
|
|
196
|
+
try:
|
|
197
|
+
try:
|
|
198
|
+
current = self._load_locked(canonical)
|
|
199
|
+
except SessionNotFoundError:
|
|
200
|
+
current = None
|
|
201
|
+
except SessionCorruptionError:
|
|
202
|
+
current = None
|
|
203
|
+
if current is not None and current.status == "active":
|
|
204
|
+
raise SessionAliasConflictError(
|
|
205
|
+
f"Active session alias already exists: {canonical}"
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
remote = await create_remote()
|
|
209
|
+
session_id = getattr(remote, "rid", None)
|
|
210
|
+
if not isinstance(session_id, str) or not session_id.strip():
|
|
211
|
+
raise SessionPersistenceError(
|
|
212
|
+
"Remote session did not return a valid rid"
|
|
213
|
+
)
|
|
214
|
+
now = datetime.now(UTC).isoformat()
|
|
215
|
+
state = SessionState(
|
|
216
|
+
session_id=session_id,
|
|
217
|
+
agent_rid=agent_rid,
|
|
218
|
+
session_token=None,
|
|
219
|
+
created_at=now,
|
|
220
|
+
last_used_at=now,
|
|
221
|
+
status="active",
|
|
222
|
+
tool_history=[],
|
|
223
|
+
)
|
|
224
|
+
try:
|
|
225
|
+
self._write_locked(canonical, state)
|
|
226
|
+
except BaseException as persistence_error:
|
|
227
|
+
compensation_error: BaseException | None = None
|
|
228
|
+
if delete_remote is not None:
|
|
229
|
+
try:
|
|
230
|
+
await delete_remote(session_id)
|
|
231
|
+
except BaseException as exc:
|
|
232
|
+
compensation_error = exc
|
|
233
|
+
if compensation_error is not None:
|
|
234
|
+
persistence_error.add_note(
|
|
235
|
+
f"Remote session compensation failed for session_id={session_id}"
|
|
236
|
+
)
|
|
237
|
+
setattr(
|
|
238
|
+
persistence_error,
|
|
239
|
+
"diagnostic_metadata",
|
|
240
|
+
{"session_id": session_id},
|
|
241
|
+
)
|
|
242
|
+
logger.error(
|
|
243
|
+
"Remote session compensation failed",
|
|
244
|
+
extra={"session_id": session_id},
|
|
245
|
+
)
|
|
246
|
+
raise
|
|
247
|
+
|
|
248
|
+
self._warn_if_many_active(agent_rid)
|
|
249
|
+
return state
|
|
250
|
+
finally:
|
|
251
|
+
lock.release()
|
|
252
|
+
|
|
253
|
+
def load(self, alias: str) -> SessionState:
|
|
254
|
+
"""Load and validate an alias record."""
|
|
255
|
+
canonical = self.normalize_alias(alias)
|
|
256
|
+
with self._lock(canonical):
|
|
257
|
+
return self._load_locked(canonical)
|
|
258
|
+
|
|
259
|
+
def update(self, alias: str, state: SessionState) -> None:
|
|
260
|
+
"""Atomically replace a validated alias record."""
|
|
261
|
+
canonical = self.normalize_alias(alias)
|
|
262
|
+
with self._lock(canonical):
|
|
263
|
+
self._validate_state(state)
|
|
264
|
+
self._write_locked(canonical, state)
|
|
265
|
+
|
|
266
|
+
def purge(self) -> int:
|
|
267
|
+
"""Delete all unlocked session records and return deletion count."""
|
|
268
|
+
deleted = 0
|
|
269
|
+
for path in self.session_root.glob("*.json"):
|
|
270
|
+
canonical = path.stem
|
|
271
|
+
lock = self._lock(canonical)
|
|
272
|
+
if not lock.acquire(blocking=False):
|
|
273
|
+
logger.warning(
|
|
274
|
+
"Skipped locked session during purge",
|
|
275
|
+
extra={"session_alias": canonical},
|
|
276
|
+
)
|
|
277
|
+
continue
|
|
278
|
+
try:
|
|
279
|
+
if path.exists():
|
|
280
|
+
path.unlink()
|
|
281
|
+
deleted += 1
|
|
282
|
+
finally:
|
|
283
|
+
lock.release()
|
|
284
|
+
return deleted
|
|
285
|
+
|
|
286
|
+
def cleanup_expired(self, now: datetime | None = None) -> int:
|
|
287
|
+
"""Delete records inactive for the configured UTC expiry period."""
|
|
288
|
+
current_time = now or datetime.now(UTC)
|
|
289
|
+
if current_time.tzinfo is None or current_time.utcoffset() is None:
|
|
290
|
+
raise ValueError("now must be timezone-aware")
|
|
291
|
+
current_time = current_time.astimezone(UTC)
|
|
292
|
+
deleted = 0
|
|
293
|
+
for path in self.session_root.glob("*.json"):
|
|
294
|
+
canonical = path.stem
|
|
295
|
+
lock = self._lock(canonical)
|
|
296
|
+
if not lock.acquire(blocking=False):
|
|
297
|
+
logger.warning(
|
|
298
|
+
"Skipped locked session during expiry cleanup",
|
|
299
|
+
extra={"session_alias": canonical},
|
|
300
|
+
)
|
|
301
|
+
continue
|
|
302
|
+
try:
|
|
303
|
+
try:
|
|
304
|
+
state = self._load_locked(canonical)
|
|
305
|
+
except (SessionNotFoundError, SessionCorruptionError):
|
|
306
|
+
continue
|
|
307
|
+
last_used = self._parse_timestamp(state.last_used_at)
|
|
308
|
+
if state.status == "expired" or current_time - last_used >= self.expiry:
|
|
309
|
+
path.unlink(missing_ok=True)
|
|
310
|
+
deleted += 1
|
|
311
|
+
finally:
|
|
312
|
+
lock.release()
|
|
313
|
+
return deleted
|
|
314
|
+
|
|
315
|
+
@classmethod
|
|
316
|
+
def normalize_alias(cls, alias: str) -> str:
|
|
317
|
+
"""Return a canonical, contained session alias."""
|
|
318
|
+
if not isinstance(alias, str):
|
|
319
|
+
raise InvalidSessionAliasError("Session alias must be a string")
|
|
320
|
+
normalized = unicodedata.normalize("NFKC", alias).strip().casefold()
|
|
321
|
+
normalized = re.sub(r"\s+", "-", normalized)
|
|
322
|
+
if (
|
|
323
|
+
not normalized.isascii()
|
|
324
|
+
or normalized in {".", ".."}
|
|
325
|
+
or "/" in normalized
|
|
326
|
+
or "\\" in normalized
|
|
327
|
+
or any(
|
|
328
|
+
ord(character) < 32 or ord(character) == 127 for character in normalized
|
|
329
|
+
)
|
|
330
|
+
or _ALIAS_PATTERN.fullmatch(normalized) is None
|
|
331
|
+
or normalized.split(".", maxsplit=1)[0] in _WINDOWS_RESERVED
|
|
332
|
+
):
|
|
333
|
+
raise InvalidSessionAliasError("Session alias is invalid")
|
|
334
|
+
return normalized
|
|
335
|
+
|
|
336
|
+
def _ensure_root(self) -> None:
|
|
337
|
+
self.session_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
338
|
+
if os.name != "nt":
|
|
339
|
+
os.chmod(self.session_root, 0o700)
|
|
340
|
+
|
|
341
|
+
def _path(self, canonical: str) -> Path:
|
|
342
|
+
path = (self.session_root / f"{canonical}.json").resolve()
|
|
343
|
+
if path.parent != self.session_root.resolve():
|
|
344
|
+
raise InvalidSessionAliasError("Session alias escapes configured path")
|
|
345
|
+
return path
|
|
346
|
+
|
|
347
|
+
def _lock(self, canonical: str) -> _AliasLock:
|
|
348
|
+
return _AliasLock(self.session_root / ".locks" / f"{canonical}.lock")
|
|
349
|
+
|
|
350
|
+
def _load_locked(self, canonical: str) -> SessionState:
|
|
351
|
+
path = self._path(canonical)
|
|
352
|
+
if not path.exists():
|
|
353
|
+
raise SessionNotFoundError(f"Session alias not found: {canonical}")
|
|
354
|
+
try:
|
|
355
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
356
|
+
if not isinstance(raw, dict):
|
|
357
|
+
raise ValueError("record is not an object")
|
|
358
|
+
token = raw.get("session_token")
|
|
359
|
+
if token is not None and not isinstance(token, str):
|
|
360
|
+
raise ValueError("session_token has invalid type")
|
|
361
|
+
state = SessionState(
|
|
362
|
+
session_id=self._required_string(raw, "session_id"),
|
|
363
|
+
agent_rid=self._required_string(raw, "agent_rid"),
|
|
364
|
+
session_token=token,
|
|
365
|
+
created_at=self._required_string(raw, "created_at"),
|
|
366
|
+
last_used_at=self._required_string(raw, "last_used_at"),
|
|
367
|
+
status=cast(Any, raw.get("status")),
|
|
368
|
+
tool_history=self._tool_history(raw.get("tool_history")),
|
|
369
|
+
)
|
|
370
|
+
self._validate_state(state)
|
|
371
|
+
return state
|
|
372
|
+
except (
|
|
373
|
+
OSError,
|
|
374
|
+
UnicodeError,
|
|
375
|
+
json.JSONDecodeError,
|
|
376
|
+
TypeError,
|
|
377
|
+
ValueError,
|
|
378
|
+
KeyError,
|
|
379
|
+
) as exc:
|
|
380
|
+
logger.warning(
|
|
381
|
+
"Removed corrupt session state",
|
|
382
|
+
extra={"session_alias": canonical},
|
|
383
|
+
)
|
|
384
|
+
path.unlink(missing_ok=True)
|
|
385
|
+
raise SessionCorruptionError(
|
|
386
|
+
f"Session state is corrupt for alias: {canonical}"
|
|
387
|
+
) from exc
|
|
388
|
+
|
|
389
|
+
def _write_locked(self, canonical: str, state: SessionState) -> None:
|
|
390
|
+
self._validate_state(state)
|
|
391
|
+
path = self._path(canonical)
|
|
392
|
+
fd, raw_temp_path = tempfile.mkstemp(
|
|
393
|
+
prefix=f".{canonical}-", suffix=".tmp", dir=self.session_root
|
|
394
|
+
)
|
|
395
|
+
temp_path = Path(raw_temp_path)
|
|
396
|
+
try:
|
|
397
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as target:
|
|
398
|
+
json.dump(
|
|
399
|
+
state.to_dict(), target, ensure_ascii=True, separators=(",", ":")
|
|
400
|
+
)
|
|
401
|
+
target.write("\n")
|
|
402
|
+
target.flush()
|
|
403
|
+
os.fsync(target.fileno())
|
|
404
|
+
if os.name != "nt":
|
|
405
|
+
os.chmod(temp_path, 0o600)
|
|
406
|
+
os.replace(temp_path, path)
|
|
407
|
+
except BaseException:
|
|
408
|
+
temp_path.unlink(missing_ok=True)
|
|
409
|
+
raise
|
|
410
|
+
|
|
411
|
+
@classmethod
|
|
412
|
+
def _validate_state(cls, state: SessionState) -> None:
|
|
413
|
+
if not state.session_id or not state.agent_rid:
|
|
414
|
+
raise ValueError("session_id and agent_rid are required")
|
|
415
|
+
if state.session_token is not None and not isinstance(state.session_token, str):
|
|
416
|
+
raise ValueError("session_token must be null or a string")
|
|
417
|
+
if state.status not in _VALID_STATUSES:
|
|
418
|
+
raise ValueError("session status is invalid")
|
|
419
|
+
cls._parse_timestamp(state.created_at)
|
|
420
|
+
cls._parse_timestamp(state.last_used_at)
|
|
421
|
+
if not isinstance(state.tool_history, list) or not all(
|
|
422
|
+
isinstance(item, dict) for item in state.tool_history
|
|
423
|
+
):
|
|
424
|
+
raise ValueError("tool_history must be a list of objects")
|
|
425
|
+
|
|
426
|
+
@staticmethod
|
|
427
|
+
def _parse_timestamp(value: str) -> datetime:
|
|
428
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
429
|
+
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
|
430
|
+
raise ValueError("session timestamps must include a UTC offset")
|
|
431
|
+
return parsed.astimezone(UTC)
|
|
432
|
+
|
|
433
|
+
@staticmethod
|
|
434
|
+
def _required_string(raw: dict[str, Any], key: str) -> str:
|
|
435
|
+
value = raw[key]
|
|
436
|
+
if not isinstance(value, str) or not value:
|
|
437
|
+
raise ValueError(f"{key} must be a non-empty string")
|
|
438
|
+
return value
|
|
439
|
+
|
|
440
|
+
@staticmethod
|
|
441
|
+
def _tool_history(value: Any) -> list[dict[str, Any]]:
|
|
442
|
+
if not isinstance(value, list) or not all(
|
|
443
|
+
isinstance(item, dict) for item in value
|
|
444
|
+
):
|
|
445
|
+
raise ValueError("tool_history must be a list of objects")
|
|
446
|
+
return cast(list[dict[str, Any]], value)
|
|
447
|
+
|
|
448
|
+
def _warn_if_many_active(self, agent_rid: str) -> None:
|
|
449
|
+
active = 0
|
|
450
|
+
for path in self.session_root.glob("*.json"):
|
|
451
|
+
try:
|
|
452
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
453
|
+
except (OSError, UnicodeError, json.JSONDecodeError):
|
|
454
|
+
continue
|
|
455
|
+
if raw.get("agent_rid") == agent_rid and raw.get("status") == "active":
|
|
456
|
+
active += 1
|
|
457
|
+
if active > 5:
|
|
458
|
+
logger.warning(
|
|
459
|
+
"Agent has more than five active sessions",
|
|
460
|
+
extra={"active_session_count": active},
|
|
461
|
+
)
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""SDK-native B3 trace context generation and isolation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
|
|
10
|
+
from pal_found_cli.common.config_loader import ConfigLoader
|
|
11
|
+
|
|
12
|
+
_LOWER_HEX = frozenset("0123456789abcdef")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class InvalidTraceContextError(ValueError):
|
|
16
|
+
"""Raised when caller-supplied B3 context is malformed."""
|
|
17
|
+
|
|
18
|
+
exit_code = 1
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class B3Context:
|
|
23
|
+
"""Validated B3 multi-header values used by the Foundry SDK."""
|
|
24
|
+
|
|
25
|
+
trace_id: str
|
|
26
|
+
span_id: str
|
|
27
|
+
sampled: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TracingProvider:
|
|
31
|
+
"""Create isolated Foundry SDK B3 contexts for one invocation."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
enabled: bool | None = None,
|
|
36
|
+
*,
|
|
37
|
+
config: ConfigLoader | None = None,
|
|
38
|
+
sampled: str = "1",
|
|
39
|
+
) -> None:
|
|
40
|
+
cfg = config or ConfigLoader()
|
|
41
|
+
self.enabled = cfg.enable_tracing if enabled is None else enabled
|
|
42
|
+
if sampled not in {"0", "1"}:
|
|
43
|
+
raise InvalidTraceContextError("B3 sampled value must be '0' or '1'")
|
|
44
|
+
self.sampled = sampled
|
|
45
|
+
|
|
46
|
+
@contextmanager
|
|
47
|
+
def scope(self, supplied: B3Context | None = None) -> Iterator[B3Context | None]:
|
|
48
|
+
"""Set SDK context variables and restore prior values on every exit."""
|
|
49
|
+
if not self.enabled:
|
|
50
|
+
yield None
|
|
51
|
+
return
|
|
52
|
+
|
|
53
|
+
context = supplied or B3Context(
|
|
54
|
+
trace_id=self._new_nonzero_hex(16),
|
|
55
|
+
span_id=self._new_nonzero_hex(8),
|
|
56
|
+
sampled=self.sampled,
|
|
57
|
+
)
|
|
58
|
+
self.validate(context)
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
from foundry_sdk import SAMPLED_VAR, SPAN_ID_VAR, TRACE_ID_VAR
|
|
62
|
+
except ImportError as exc:
|
|
63
|
+
from pal_found_cli.common.config_loader import ConfigurationError
|
|
64
|
+
|
|
65
|
+
raise ConfigurationError(
|
|
66
|
+
"foundry-sdk not installed; tracing context is unavailable"
|
|
67
|
+
) from exc
|
|
68
|
+
|
|
69
|
+
trace_token = TRACE_ID_VAR.set(context.trace_id)
|
|
70
|
+
span_token = SPAN_ID_VAR.set(context.span_id)
|
|
71
|
+
sampled_token = SAMPLED_VAR.set(context.sampled)
|
|
72
|
+
try:
|
|
73
|
+
yield context
|
|
74
|
+
finally:
|
|
75
|
+
SAMPLED_VAR.reset(sampled_token)
|
|
76
|
+
SPAN_ID_VAR.reset(span_token)
|
|
77
|
+
TRACE_ID_VAR.reset(trace_token)
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def validate(context: B3Context) -> None:
|
|
81
|
+
"""Validate lowercase, nonzero 128-bit/64-bit B3 identifiers."""
|
|
82
|
+
if not TracingProvider._valid_hex(context.trace_id, 32):
|
|
83
|
+
raise InvalidTraceContextError(
|
|
84
|
+
"B3 trace_id must be 32 lowercase hexadecimal characters and nonzero"
|
|
85
|
+
)
|
|
86
|
+
if not TracingProvider._valid_hex(context.span_id, 16):
|
|
87
|
+
raise InvalidTraceContextError(
|
|
88
|
+
"B3 span_id must be 16 lowercase hexadecimal characters and nonzero"
|
|
89
|
+
)
|
|
90
|
+
if context.sampled not in {"0", "1"}:
|
|
91
|
+
raise InvalidTraceContextError("B3 sampled value must be '0' or '1'")
|
|
92
|
+
|
|
93
|
+
@staticmethod
|
|
94
|
+
def _valid_hex(value: str, length: int) -> bool:
|
|
95
|
+
return (
|
|
96
|
+
isinstance(value, str)
|
|
97
|
+
and len(value) == length
|
|
98
|
+
and value != "0" * length
|
|
99
|
+
and all(character in _LOWER_HEX for character in value)
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
@staticmethod
|
|
103
|
+
def _new_nonzero_hex(byte_count: int) -> str:
|
|
104
|
+
value = "0" * (byte_count * 2)
|
|
105
|
+
while value == "0" * (byte_count * 2):
|
|
106
|
+
value = secrets.token_hex(byte_count)
|
|
107
|
+
return value
|