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.
Files changed (89) hide show
  1. pal_found_cli/__init__.py +1 -0
  2. pal_found_cli/_version.py +24 -0
  3. pal_found_cli/admin/__init__.py +2 -0
  4. pal_found_cli/admin/scripts/__init__.py +2 -0
  5. pal_found_cli/admin/scripts/pal_found_admin_cli.py +600 -0
  6. pal_found_cli/aip_agents/__init__.py +1 -0
  7. pal_found_cli/aip_agents/metadata-allow-list.md +20 -0
  8. pal_found_cli/aip_agents/scripts/__init__.py +1 -0
  9. pal_found_cli/aip_agents/scripts/pal_found_aip_agents_cli.py +704 -0
  10. pal_found_cli/audit/__init__.py +1 -0
  11. pal_found_cli/audit/metadata-allow-list.md +4 -0
  12. pal_found_cli/audit/scripts/__init__.py +1 -0
  13. pal_found_cli/audit/scripts/pal_found_audit_cli.py +387 -0
  14. pal_found_cli/checkpoints/__init__.py +9 -0
  15. pal_found_cli/checkpoints/metadata-allow-list.md +7 -0
  16. pal_found_cli/checkpoints/scripts/__init__.py +1 -0
  17. pal_found_cli/checkpoints/scripts/pal_found_checkpoints_cli.py +527 -0
  18. pal_found_cli/common/__init__.py +56 -0
  19. pal_found_cli/common/access_control_guard.py +591 -0
  20. pal_found_cli/common/async_client_factory.py +161 -0
  21. pal_found_cli/common/auth_provider.py +82 -0
  22. pal_found_cli/common/binary_download_handler.py +288 -0
  23. pal_found_cli/common/config_loader.py +388 -0
  24. pal_found_cli/common/error_serializer.py +412 -0
  25. pal_found_cli/common/log_setup.py +241 -0
  26. pal_found_cli/common/metadata-allow-list.md +169 -0
  27. pal_found_cli/common/output_formatter.py +267 -0
  28. pal_found_cli/common/pagination_helper.py +212 -0
  29. pal_found_cli/common/retry.py +617 -0
  30. pal_found_cli/common/sdk_error_utils.py +112 -0
  31. pal_found_cli/common/session_manager.py +461 -0
  32. pal_found_cli/common/tracing_provider.py +107 -0
  33. pal_found_cli/connectivity/__init__.py +9 -0
  34. pal_found_cli/connectivity/metadata-allow-list.md +24 -0
  35. pal_found_cli/connectivity/scripts/__init__.py +1 -0
  36. pal_found_cli/connectivity/scripts/pal_found_connectivity_cli.py +720 -0
  37. pal_found_cli/data_health/__init__.py +9 -0
  38. pal_found_cli/data_health/metadata-allow-list.md +10 -0
  39. pal_found_cli/data_health/scripts/__init__.py +1 -0
  40. pal_found_cli/data_health/scripts/pal_found_data_health_cli.py +458 -0
  41. pal_found_cli/datasets/__init__.py +1 -0
  42. pal_found_cli/datasets/scripts/__init__.py +1 -0
  43. pal_found_cli/datasets/scripts/pal_found_datasets_cli.py +732 -0
  44. pal_found_cli/filesystem/__init__.py +2 -0
  45. pal_found_cli/filesystem/scripts/__init__.py +2 -0
  46. pal_found_cli/filesystem/scripts/pal_found_filesystem_cli.py +659 -0
  47. pal_found_cli/functions/__init__.py +2 -0
  48. pal_found_cli/functions/scripts/__init__.py +2 -0
  49. pal_found_cli/functions/scripts/pal_found_functions_cli.py +389 -0
  50. pal_found_cli/language_models/__init__.py +1 -0
  51. pal_found_cli/language_models/metadata-allow-list.md +6 -0
  52. pal_found_cli/language_models/scripts/__init__.py +1 -0
  53. pal_found_cli/language_models/scripts/pal_found_language_models_cli.py +342 -0
  54. pal_found_cli/media_sets/__init__.py +9 -0
  55. pal_found_cli/media_sets/metadata-allow-list.md +23 -0
  56. pal_found_cli/media_sets/scripts/__init__.py +1 -0
  57. pal_found_cli/media_sets/scripts/pal_found_media_sets_cli.py +642 -0
  58. pal_found_cli/models/__init__.py +5 -0
  59. pal_found_cli/models/metadata-allow-list.md +27 -0
  60. pal_found_cli/models/scripts/__init__.py +1 -0
  61. pal_found_cli/models/scripts/pal_found_models_cli.py +714 -0
  62. pal_found_cli/ontologies/__init__.py +1 -0
  63. pal_found_cli/ontologies/scripts/__init__.py +1 -0
  64. pal_found_cli/ontologies/scripts/pal_found_ontologies_cli.py +1225 -0
  65. pal_found_cli/orchestration/__init__.py +9 -0
  66. pal_found_cli/orchestration/metadata-allow-list.md +24 -0
  67. pal_found_cli/orchestration/scripts/__init__.py +1 -0
  68. pal_found_cli/orchestration/scripts/pal_found_orchestration_cli.py +640 -0
  69. pal_found_cli/sql_queries/__init__.py +9 -0
  70. pal_found_cli/sql_queries/metadata-allow-list.md +9 -0
  71. pal_found_cli/sql_queries/scripts/__init__.py +1 -0
  72. pal_found_cli/sql_queries/scripts/pal_found_sql_queries_cli.py +514 -0
  73. pal_found_cli/streams/__init__.py +9 -0
  74. pal_found_cli/streams/metadata-allow-list.md +19 -0
  75. pal_found_cli/streams/scripts/__init__.py +1 -0
  76. pal_found_cli/streams/scripts/pal_found_streams_cli.py +615 -0
  77. pal_found_cli/third_party_applications/__init__.py +9 -0
  78. pal_found_cli/third_party_applications/metadata-allow-list.md +13 -0
  79. pal_found_cli/third_party_applications/scripts/__init__.py +1 -0
  80. pal_found_cli/third_party_applications/scripts/pal_found_third_party_applications_cli.py +547 -0
  81. pal_found_cli/widgets/__init__.py +9 -0
  82. pal_found_cli/widgets/metadata-allow-list.md +12 -0
  83. pal_found_cli/widgets/scripts/__init__.py +1 -0
  84. pal_found_cli/widgets/scripts/pal_found_widgets_cli.py +571 -0
  85. pal_found_cli-0.1.2.dist-info/METADATA +74 -0
  86. pal_found_cli-0.1.2.dist-info/RECORD +89 -0
  87. pal_found_cli-0.1.2.dist-info/WHEEL +5 -0
  88. pal_found_cli-0.1.2.dist-info/entry_points.txt +19 -0
  89. 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
@@ -0,0 +1,9 @@
1
+ """Foundry Connectivity CLI package."""
2
+
3
+ from pal_found_cli.connectivity.scripts.pal_found_connectivity_cli import (
4
+ build_parser,
5
+ console_main,
6
+ main,
7
+ )
8
+
9
+ __all__ = ["build_parser", "console_main", "main"]