chuk-artifacts 0.1.1__py3-none-any.whl → 0.1.3__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.
- chuk_artifacts/admin.py +24 -18
- chuk_artifacts/core.py +94 -120
- chuk_artifacts/metadata.py +139 -240
- chuk_artifacts/presigned.py +59 -23
- chuk_artifacts/session/__init__.py +0 -0
- chuk_artifacts/session/session_manager.py +196 -0
- chuk_artifacts/{session_operations.py → session/session_operations.py} +46 -125
- chuk_artifacts/store.py +353 -267
- {chuk_artifacts-0.1.1.dist-info → chuk_artifacts-0.1.3.dist-info}/METADATA +200 -191
- {chuk_artifacts-0.1.1.dist-info → chuk_artifacts-0.1.3.dist-info}/RECORD +13 -11
- {chuk_artifacts-0.1.1.dist-info → chuk_artifacts-0.1.3.dist-info}/WHEEL +0 -0
- {chuk_artifacts-0.1.1.dist-info → chuk_artifacts-0.1.3.dist-info}/licenses/LICENSE +0 -0
- {chuk_artifacts-0.1.1.dist-info → chuk_artifacts-0.1.3.dist-info}/top_level.txt +0 -0
chuk_artifacts/store.py
CHANGED
@@ -1,12 +1,18 @@
|
|
1
1
|
# -*- coding: utf-8 -*-
|
2
|
-
# chuk_artifacts/store.py
|
2
|
+
# chuk_artifacts/store.py
|
3
3
|
"""
|
4
|
-
|
4
|
+
Clean ArtifactStore with mandatory sessions and grid architecture.
|
5
|
+
|
6
|
+
Grid Architecture:
|
7
|
+
- Mandatory session allocation (no anonymous artifacts)
|
8
|
+
- Grid paths: grid/{sandbox_id}/{session_id}/{artifact_id}
|
9
|
+
- Clean, focused implementation
|
5
10
|
"""
|
6
11
|
|
7
12
|
from __future__ import annotations
|
8
13
|
|
9
|
-
import os, logging
|
14
|
+
import os, logging, uuid
|
15
|
+
from datetime import datetime
|
10
16
|
from typing import Any, Dict, List, Callable, AsyncContextManager, Optional, Union
|
11
17
|
|
12
18
|
try:
|
@@ -25,7 +31,8 @@ except ImportError:
|
|
25
31
|
logger.debug("python-dotenv not available, skipping .env file loading")
|
26
32
|
|
27
33
|
# Import exceptions
|
28
|
-
from .exceptions import ArtifactStoreError
|
34
|
+
from .exceptions import ArtifactStoreError, ProviderError
|
35
|
+
from .session.session_manager import SessionManager
|
29
36
|
|
30
37
|
# Configure structured logging
|
31
38
|
logger = logging.getLogger(__name__)
|
@@ -51,104 +58,73 @@ def _default_session_factory() -> Callable[[], AsyncContextManager]:
|
|
51
58
|
# ─────────────────────────────────────────────────────────────────────
|
52
59
|
class ArtifactStore:
|
53
60
|
"""
|
54
|
-
|
61
|
+
Clean ArtifactStore with grid architecture and mandatory sessions.
|
55
62
|
|
56
|
-
|
63
|
+
Simple rules:
|
64
|
+
- Always allocate a session (no anonymous artifacts)
|
65
|
+
- Grid paths only: grid/{sandbox_id}/{session_id}/{artifact_id}
|
66
|
+
- Clean, focused implementation
|
57
67
|
"""
|
58
68
|
|
59
69
|
def __init__(
|
60
70
|
self,
|
61
71
|
*,
|
62
72
|
bucket: Optional[str] = None,
|
63
|
-
s3_factory: Optional[Callable[[], AsyncContextManager]] = None,
|
64
73
|
storage_provider: Optional[str] = None,
|
65
|
-
session_factory: Optional[Callable[[], AsyncContextManager]] = None,
|
66
74
|
session_provider: Optional[str] = None,
|
75
|
+
sandbox_id: Optional[str] = None,
|
76
|
+
session_ttl_hours: int = 24,
|
67
77
|
max_retries: int = 3,
|
68
|
-
# Backward compatibility - deprecated but still supported
|
69
|
-
redis_url: Optional[str] = None,
|
70
|
-
provider: Optional[str] = None,
|
71
78
|
):
|
72
|
-
#
|
73
|
-
bucket = bucket or os.getenv("ARTIFACT_BUCKET", "
|
79
|
+
# Configuration
|
80
|
+
self.bucket = bucket or os.getenv("ARTIFACT_BUCKET", "artifacts")
|
81
|
+
self.sandbox_id = sandbox_id or self._detect_sandbox_id()
|
82
|
+
self.session_ttl_hours = session_ttl_hours
|
83
|
+
self.max_retries = max_retries
|
84
|
+
self._closed = False
|
85
|
+
|
86
|
+
# Storage provider
|
74
87
|
storage_provider = storage_provider or os.getenv("ARTIFACT_PROVIDER", "memory")
|
88
|
+
self._s3_factory = self._load_storage_provider(storage_provider)
|
89
|
+
self._storage_provider_name = storage_provider
|
90
|
+
|
91
|
+
# Session provider
|
75
92
|
session_provider = session_provider or os.getenv("SESSION_PROVIDER", "memory")
|
93
|
+
self._session_factory = self._load_session_provider(session_provider)
|
94
|
+
self._session_provider_name = session_provider
|
76
95
|
|
77
|
-
#
|
78
|
-
|
79
|
-
|
80
|
-
|
81
|
-
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
95
|
-
|
96
|
-
storage_provider = provider
|
97
|
-
|
98
|
-
# Validate factory/provider combinations
|
99
|
-
if s3_factory and storage_provider:
|
100
|
-
raise ValueError("Specify either s3_factory or storage_provider—not both")
|
101
|
-
if session_factory and session_provider:
|
102
|
-
raise ValueError("Specify either session_factory or session_provider—not both")
|
103
|
-
|
104
|
-
# Initialize storage factory
|
105
|
-
if s3_factory:
|
106
|
-
self._s3_factory = s3_factory
|
107
|
-
elif storage_provider:
|
108
|
-
self._s3_factory = self._load_storage_provider(storage_provider)
|
109
|
-
else:
|
110
|
-
self._s3_factory = _default_storage_factory()
|
111
|
-
|
112
|
-
# Initialize session factory
|
113
|
-
if session_factory:
|
114
|
-
self._session_factory = session_factory
|
115
|
-
elif session_provider:
|
116
|
-
self._session_factory = self._load_session_provider(session_provider)
|
117
|
-
else:
|
118
|
-
self._session_factory = _default_session_factory()
|
119
|
-
|
120
|
-
self.bucket = bucket
|
121
|
-
self.max_retries = max_retries
|
122
|
-
self._storage_provider_name = storage_provider or "memory"
|
123
|
-
self._session_provider_name = session_provider or "memory"
|
124
|
-
self._closed = False
|
125
|
-
|
126
|
-
# Initialize operation modules
|
127
|
-
from .core import CoreStorageOperations
|
128
|
-
from .presigned import PresignedURLOperations
|
129
|
-
from .metadata import MetadataOperations
|
130
|
-
from .batch import BatchOperations
|
131
|
-
from .admin import AdminOperations
|
132
|
-
from .session_operations import SessionOperations
|
96
|
+
# Session manager (always enabled)
|
97
|
+
self._session_manager = SessionManager(
|
98
|
+
sandbox_id=self.sandbox_id,
|
99
|
+
session_factory=self._session_factory,
|
100
|
+
default_ttl_hours=session_ttl_hours,
|
101
|
+
)
|
102
|
+
|
103
|
+
# Operation modules
|
104
|
+
from .core import CoreStorageOperations as CoreOps
|
105
|
+
from .metadata import MetadataOperations as MetaOps
|
106
|
+
from .presigned import PresignedURLOperations as PresignedOps
|
107
|
+
from .batch import BatchOperations as BatchOps
|
108
|
+
from .admin import AdminOperations as AdminOps
|
109
|
+
|
110
|
+
self._core = CoreOps(self)
|
111
|
+
self._metadata = MetaOps(self)
|
112
|
+
self._presigned = PresignedOps(self)
|
113
|
+
self._batch = BatchOps(self)
|
114
|
+
self._admin = AdminOps(self)
|
133
115
|
|
134
|
-
self._core = CoreStorageOperations(self)
|
135
|
-
self._presigned = PresignedURLOperations(self)
|
136
|
-
self._metadata = MetadataOperations(self)
|
137
|
-
self._batch = BatchOperations(self)
|
138
|
-
self._admin = AdminOperations(self)
|
139
|
-
self._session = SessionOperations(self)
|
140
|
-
|
141
116
|
logger.info(
|
142
|
-
"ArtifactStore initialized
|
117
|
+
"ArtifactStore initialized",
|
143
118
|
extra={
|
144
|
-
"bucket": bucket,
|
145
|
-
"
|
146
|
-
"
|
119
|
+
"bucket": self.bucket,
|
120
|
+
"sandbox_id": self.sandbox_id,
|
121
|
+
"storage_provider": storage_provider,
|
122
|
+
"session_provider": session_provider,
|
147
123
|
}
|
148
124
|
)
|
149
125
|
|
150
126
|
# ─────────────────────────────────────────────────────────────────
|
151
|
-
# Core
|
127
|
+
# Core operations
|
152
128
|
# ─────────────────────────────────────────────────────────────────
|
153
129
|
|
154
130
|
async def store(
|
@@ -160,11 +136,22 @@ class ArtifactStore:
|
|
160
136
|
meta: Dict[str, Any] | None = None,
|
161
137
|
filename: str | None = None,
|
162
138
|
session_id: str | None = None,
|
139
|
+
user_id: str | None = None,
|
163
140
|
ttl: int = _DEFAULT_TTL,
|
164
141
|
) -> str:
|
165
|
-
"""Store artifact
|
166
|
-
|
167
|
-
|
142
|
+
"""Store artifact with mandatory session allocation."""
|
143
|
+
# Always allocate/validate session
|
144
|
+
session_id = await self._session_manager.allocate_session(
|
145
|
+
session_id=session_id,
|
146
|
+
user_id=user_id,
|
147
|
+
)
|
148
|
+
|
149
|
+
# Work around the naming conflict in CoreStorageOperations
|
150
|
+
# where self.store is the artifact store instance, not the method
|
151
|
+
core_store_method = getattr(self._core.__class__, 'store')
|
152
|
+
return await core_store_method(
|
153
|
+
self._core,
|
154
|
+
data=data,
|
168
155
|
mime=mime,
|
169
156
|
summary=summary,
|
170
157
|
meta=meta,
|
@@ -174,11 +161,226 @@ class ArtifactStore:
|
|
174
161
|
)
|
175
162
|
|
176
163
|
async def retrieve(self, artifact_id: str) -> bytes:
|
177
|
-
"""Retrieve artifact data
|
164
|
+
"""Retrieve artifact data."""
|
178
165
|
return await self._core.retrieve(artifact_id)
|
179
166
|
|
167
|
+
async def metadata(self, artifact_id: str) -> Dict[str, Any]:
|
168
|
+
"""Get artifact metadata."""
|
169
|
+
return await self._metadata.get_metadata(artifact_id)
|
170
|
+
|
171
|
+
async def exists(self, artifact_id: str) -> bool:
|
172
|
+
"""Check if artifact exists."""
|
173
|
+
return await self._metadata.exists(artifact_id)
|
174
|
+
|
175
|
+
async def delete(self, artifact_id: str) -> bool:
|
176
|
+
"""Delete artifact."""
|
177
|
+
return await self._metadata.delete(artifact_id)
|
178
|
+
|
179
|
+
async def list_by_session(self, session_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
180
|
+
"""List artifacts in session."""
|
181
|
+
return await self._metadata.list_by_session(session_id, limit)
|
182
|
+
|
183
|
+
# ─────────────────────────────────────────────────────────────────
|
184
|
+
# Session operations
|
185
|
+
# ─────────────────────────────────────────────────────────────────
|
186
|
+
|
187
|
+
async def create_session(
|
188
|
+
self,
|
189
|
+
user_id: Optional[str] = None,
|
190
|
+
ttl_hours: Optional[int] = None,
|
191
|
+
) -> str:
|
192
|
+
"""Create a new session."""
|
193
|
+
return await self._session_manager.allocate_session(
|
194
|
+
user_id=user_id,
|
195
|
+
ttl_hours=ttl_hours,
|
196
|
+
)
|
197
|
+
|
198
|
+
async def validate_session(self, session_id: str) -> bool:
|
199
|
+
"""Validate session."""
|
200
|
+
return await self._session_manager.validate_session(session_id)
|
201
|
+
|
202
|
+
async def get_session_info(self, session_id: str) -> Optional[Dict[str, Any]]:
|
203
|
+
"""Get session information."""
|
204
|
+
return await self._session_manager.get_session_info(session_id)
|
205
|
+
|
206
|
+
# ─────────────────────────────────────────────────────────────────
|
207
|
+
# Grid operations
|
208
|
+
# ─────────────────────────────────────────────────────────────────
|
209
|
+
|
210
|
+
def get_canonical_prefix(self, session_id: str) -> str:
|
211
|
+
"""Get grid path prefix for session."""
|
212
|
+
return self._session_manager.get_canonical_prefix(session_id)
|
213
|
+
|
214
|
+
def generate_artifact_key(self, session_id: str, artifact_id: str) -> str:
|
215
|
+
"""Generate grid artifact key."""
|
216
|
+
return self._session_manager.generate_artifact_key(session_id, artifact_id)
|
217
|
+
|
218
|
+
# ─────────────────────────────────────────────────────────────────
|
219
|
+
# File operations
|
220
|
+
# ─────────────────────────────────────────────────────────────────
|
221
|
+
|
222
|
+
async def write_file(
|
223
|
+
self,
|
224
|
+
content: Union[str, bytes],
|
225
|
+
*,
|
226
|
+
filename: str,
|
227
|
+
mime: str = "text/plain",
|
228
|
+
summary: str = "",
|
229
|
+
session_id: str = None,
|
230
|
+
user_id: str = None,
|
231
|
+
meta: Dict[str, Any] = None,
|
232
|
+
encoding: str = "utf-8",
|
233
|
+
) -> str:
|
234
|
+
"""Write content to file."""
|
235
|
+
if isinstance(content, str):
|
236
|
+
data = content.encode(encoding)
|
237
|
+
else:
|
238
|
+
data = content
|
239
|
+
|
240
|
+
return await self.store(
|
241
|
+
data=data,
|
242
|
+
mime=mime,
|
243
|
+
summary=summary or f"File: {filename}",
|
244
|
+
filename=filename,
|
245
|
+
session_id=session_id,
|
246
|
+
user_id=user_id,
|
247
|
+
meta=meta,
|
248
|
+
)
|
249
|
+
|
250
|
+
async def read_file(
|
251
|
+
self,
|
252
|
+
artifact_id: str,
|
253
|
+
*,
|
254
|
+
encoding: str = "utf-8",
|
255
|
+
as_text: bool = True
|
256
|
+
) -> Union[str, bytes]:
|
257
|
+
"""Read file content."""
|
258
|
+
data = await self.retrieve(artifact_id)
|
259
|
+
|
260
|
+
if as_text:
|
261
|
+
return data.decode(encoding)
|
262
|
+
return data
|
263
|
+
|
264
|
+
async def list_files(
|
265
|
+
self,
|
266
|
+
session_id: str,
|
267
|
+
prefix: str = "",
|
268
|
+
limit: int = 100
|
269
|
+
) -> List[Dict[str, Any]]:
|
270
|
+
"""List files in session with optional prefix filter."""
|
271
|
+
return await self._metadata.list_by_prefix(session_id, prefix, limit)
|
272
|
+
|
273
|
+
async def get_directory_contents(
|
274
|
+
self,
|
275
|
+
session_id: str,
|
276
|
+
directory_prefix: str = "",
|
277
|
+
limit: int = 100
|
278
|
+
) -> List[Dict[str, Any]]:
|
279
|
+
"""
|
280
|
+
List files in a directory-like structure within a session.
|
281
|
+
"""
|
282
|
+
try:
|
283
|
+
return await self._metadata.list_by_prefix(session_id, directory_prefix, limit)
|
284
|
+
except Exception as e:
|
285
|
+
logger.error(
|
286
|
+
"Directory listing failed for session %s: %s",
|
287
|
+
session_id,
|
288
|
+
str(e),
|
289
|
+
extra={
|
290
|
+
"session_id": session_id,
|
291
|
+
"directory_prefix": directory_prefix,
|
292
|
+
"operation": "get_directory_contents"
|
293
|
+
}
|
294
|
+
)
|
295
|
+
raise ProviderError(f"Directory listing failed: {e}") from e
|
296
|
+
|
297
|
+
async def copy_file(
|
298
|
+
self,
|
299
|
+
artifact_id: str,
|
300
|
+
*,
|
301
|
+
new_filename: str = None,
|
302
|
+
target_session_id: str = None,
|
303
|
+
new_meta: Dict[str, Any] = None,
|
304
|
+
summary: str = None
|
305
|
+
) -> str:
|
306
|
+
"""Copy a file WITHIN THE SAME SESSION only (security enforced)."""
|
307
|
+
# Get original metadata to check session
|
308
|
+
original_meta = await self.metadata(artifact_id)
|
309
|
+
original_session = original_meta.get("session_id")
|
310
|
+
|
311
|
+
# STRICT SECURITY: Block ALL cross-session copies
|
312
|
+
if target_session_id and target_session_id != original_session:
|
313
|
+
raise ArtifactStoreError(
|
314
|
+
f"Cross-session copies are not permitted for security reasons. "
|
315
|
+
f"Artifact {artifact_id} belongs to session '{original_session}', "
|
316
|
+
f"cannot copy to session '{target_session_id}'. Files can only be "
|
317
|
+
f"copied within the same session."
|
318
|
+
)
|
319
|
+
|
320
|
+
# Get original data
|
321
|
+
original_data = await self.retrieve(artifact_id)
|
322
|
+
|
323
|
+
# Prepare copy metadata
|
324
|
+
copy_filename = new_filename or (
|
325
|
+
(original_meta.get("filename", "file") or "file") + "_copy"
|
326
|
+
)
|
327
|
+
copy_summary = summary or f"Copy of {original_meta.get('summary', 'artifact')}"
|
328
|
+
|
329
|
+
# Merge metadata
|
330
|
+
copy_meta = {**original_meta.get("meta", {})}
|
331
|
+
if new_meta:
|
332
|
+
copy_meta.update(new_meta)
|
333
|
+
|
334
|
+
# Add copy tracking
|
335
|
+
copy_meta["copied_from"] = artifact_id
|
336
|
+
copy_meta["copy_timestamp"] = datetime.utcnow().isoformat() + "Z"
|
337
|
+
|
338
|
+
# Store the copy in the same session
|
339
|
+
return await self.store(
|
340
|
+
data=original_data,
|
341
|
+
mime=original_meta["mime"],
|
342
|
+
summary=copy_summary,
|
343
|
+
filename=copy_filename,
|
344
|
+
session_id=original_session, # Always same session
|
345
|
+
meta=copy_meta
|
346
|
+
)
|
347
|
+
|
348
|
+
async def move_file(
|
349
|
+
self,
|
350
|
+
artifact_id: str,
|
351
|
+
*,
|
352
|
+
new_filename: str = None,
|
353
|
+
new_session_id: str = None,
|
354
|
+
new_meta: Dict[str, Any] = None
|
355
|
+
) -> Dict[str, Any]:
|
356
|
+
"""Move/rename a file WITHIN THE SAME SESSION only (security enforced)."""
|
357
|
+
# Get current metadata
|
358
|
+
record = await self.metadata(artifact_id)
|
359
|
+
current_session = record.get("session_id")
|
360
|
+
|
361
|
+
# STRICT SECURITY: Block ALL cross-session moves
|
362
|
+
if new_session_id and new_session_id != current_session:
|
363
|
+
raise ArtifactStoreError(
|
364
|
+
f"Cross-session moves are not permitted for security reasons. "
|
365
|
+
f"Artifact {artifact_id} belongs to session '{current_session}', "
|
366
|
+
f"cannot move to session '{new_session_id}'. Use copy operations within "
|
367
|
+
f"the same session only."
|
368
|
+
)
|
369
|
+
|
370
|
+
# For now, just simulate a move by updating metadata
|
371
|
+
# A full implementation would update the metadata record
|
372
|
+
if new_filename:
|
373
|
+
# This is a simplified move - just return updated record
|
374
|
+
record["filename"] = new_filename
|
375
|
+
if new_meta:
|
376
|
+
existing_meta = record.get("meta", {})
|
377
|
+
existing_meta.update(new_meta)
|
378
|
+
record["meta"] = existing_meta
|
379
|
+
|
380
|
+
return record
|
381
|
+
|
180
382
|
# ─────────────────────────────────────────────────────────────────
|
181
|
-
# Presigned URL operations
|
383
|
+
# Presigned URL operations
|
182
384
|
# ─────────────────────────────────────────────────────────────────
|
183
385
|
|
184
386
|
async def presign(self, artifact_id: str, expires: int = _DEFAULT_PRESIGN_EXPIRES) -> str:
|
@@ -205,12 +407,7 @@ class ArtifactStore:
|
|
205
407
|
expires: int = _DEFAULT_PRESIGN_EXPIRES
|
206
408
|
) -> tuple[str, str]:
|
207
409
|
"""Generate a presigned URL for uploading a new artifact."""
|
208
|
-
return await self._presigned.presign_upload(
|
209
|
-
session_id=session_id,
|
210
|
-
filename=filename,
|
211
|
-
mime_type=mime_type,
|
212
|
-
expires=expires
|
213
|
-
)
|
410
|
+
return await self._presigned.presign_upload(session_id, filename, mime_type, expires)
|
214
411
|
|
215
412
|
async def register_uploaded_artifact(
|
216
413
|
self,
|
@@ -257,76 +454,50 @@ class ArtifactStore:
|
|
257
454
|
)
|
258
455
|
|
259
456
|
# ─────────────────────────────────────────────────────────────────
|
260
|
-
#
|
457
|
+
# Batch operations
|
261
458
|
# ─────────────────────────────────────────────────────────────────
|
262
459
|
|
263
|
-
async def
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
460
|
+
async def store_batch(
|
461
|
+
self,
|
462
|
+
items: List[Dict[str, Any]],
|
463
|
+
session_id: str | None = None,
|
464
|
+
ttl: int = _DEFAULT_TTL,
|
465
|
+
) -> List[str]:
|
466
|
+
"""Store multiple artifacts in a batch operation."""
|
467
|
+
return await self._batch.store_batch(items, session_id, ttl)
|
270
468
|
|
271
|
-
|
272
|
-
|
273
|
-
|
469
|
+
# ─────────────────────────────────────────────────────────────────
|
470
|
+
# Metadata operations
|
471
|
+
# ─────────────────────────────────────────────────────────────────
|
274
472
|
|
275
473
|
async def update_metadata(
|
276
|
-
self,
|
277
|
-
artifact_id: str,
|
474
|
+
self,
|
475
|
+
artifact_id: str,
|
278
476
|
*,
|
279
477
|
summary: str = None,
|
280
478
|
meta: Dict[str, Any] = None,
|
281
|
-
|
282
|
-
|
283
|
-
# NEW: MCP-specific parameters
|
284
|
-
new_meta: Dict[str, Any] = None,
|
285
|
-
merge: bool = True
|
479
|
+
merge: bool = True,
|
480
|
+
**kwargs
|
286
481
|
) -> Dict[str, Any]:
|
287
|
-
"""Update artifact metadata
|
482
|
+
"""Update artifact metadata."""
|
288
483
|
return await self._metadata.update_metadata(
|
289
484
|
artifact_id,
|
290
485
|
summary=summary,
|
291
486
|
meta=meta,
|
292
|
-
|
293
|
-
|
294
|
-
new_meta=new_meta,
|
295
|
-
merge=merge
|
487
|
+
merge=merge,
|
488
|
+
**kwargs
|
296
489
|
)
|
297
490
|
|
298
|
-
async def extend_ttl(
|
299
|
-
"""Extend the TTL of an artifact's metadata."""
|
300
|
-
return await self._metadata.extend_ttl(artifact_id, additional_seconds)
|
301
|
-
|
302
|
-
async def list_by_session(self, session_id: str, limit: int = 100) -> List[Dict[str, Any]]:
|
303
|
-
"""List artifacts for a specific session."""
|
304
|
-
return await self._metadata.list_by_session(session_id, limit)
|
305
|
-
|
306
|
-
async def list_by_prefix(
|
307
|
-
self,
|
308
|
-
session_id: str,
|
309
|
-
prefix: str = "",
|
310
|
-
limit: int = 100
|
311
|
-
) -> List[Dict[str, Any]]:
|
312
|
-
"""List artifacts in a session with filename prefix filtering."""
|
313
|
-
return await self._metadata.list_by_prefix(session_id, prefix, limit)
|
314
|
-
|
315
|
-
# ─────────────────────────────────────────────────────────────────
|
316
|
-
# Batch operations (delegated to BatchOperations)
|
317
|
-
# ─────────────────────────────────────────────────────────────────
|
318
|
-
|
319
|
-
async def store_batch(
|
491
|
+
async def extend_ttl(
|
320
492
|
self,
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
325
|
-
|
326
|
-
return await self._batch.store_batch(items, session_id, ttl)
|
493
|
+
artifact_id: str,
|
494
|
+
additional_seconds: int
|
495
|
+
) -> Dict[str, Any]:
|
496
|
+
"""Extend artifact TTL."""
|
497
|
+
return await self._metadata.extend_ttl(artifact_id, additional_seconds)
|
327
498
|
|
328
499
|
# ─────────────────────────────────────────────────────────────────
|
329
|
-
# Administrative operations
|
500
|
+
# Administrative operations
|
330
501
|
# ─────────────────────────────────────────────────────────────────
|
331
502
|
|
332
503
|
async def validate_configuration(self) -> Dict[str, Any]:
|
@@ -338,100 +509,53 @@ class ArtifactStore:
|
|
338
509
|
return await self._admin.get_stats()
|
339
510
|
|
340
511
|
# ─────────────────────────────────────────────────────────────────
|
341
|
-
#
|
512
|
+
# Helpers
|
342
513
|
# ─────────────────────────────────────────────────────────────────
|
343
514
|
|
344
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
|
355
|
-
|
356
|
-
|
357
|
-
|
358
|
-
|
359
|
-
|
360
|
-
async def copy_file(
|
361
|
-
self,
|
362
|
-
artifact_id: str,
|
363
|
-
*,
|
364
|
-
new_filename: str = None,
|
365
|
-
target_session_id: str = None,
|
366
|
-
new_meta: Dict[str, Any] = None,
|
367
|
-
summary: str = None
|
368
|
-
) -> str:
|
369
|
-
"""Copy a file within or across sessions."""
|
370
|
-
return await self._session.copy_file(
|
371
|
-
artifact_id,
|
372
|
-
new_filename=new_filename,
|
373
|
-
target_session_id=target_session_id,
|
374
|
-
new_meta=new_meta,
|
375
|
-
summary=summary
|
376
|
-
)
|
377
|
-
|
378
|
-
async def read_file(
|
379
|
-
self,
|
380
|
-
artifact_id: str,
|
381
|
-
*,
|
382
|
-
encoding: str = "utf-8",
|
383
|
-
as_text: bool = True
|
384
|
-
) -> Union[str, bytes]:
|
385
|
-
"""Read file content directly."""
|
386
|
-
return await self._session.read_file(
|
387
|
-
artifact_id,
|
388
|
-
encoding=encoding,
|
389
|
-
as_text=as_text
|
390
|
-
)
|
515
|
+
def _detect_sandbox_id(self) -> str:
|
516
|
+
"""Auto-detect sandbox ID."""
|
517
|
+
candidates = [
|
518
|
+
os.getenv("ARTIFACT_SANDBOX_ID"),
|
519
|
+
os.getenv("SANDBOX_ID"),
|
520
|
+
os.getenv("HOSTNAME"),
|
521
|
+
]
|
522
|
+
|
523
|
+
for candidate in candidates:
|
524
|
+
if candidate:
|
525
|
+
clean_id = "".join(c for c in candidate if c.isalnum() or c in "-_")[:32]
|
526
|
+
if clean_id:
|
527
|
+
return clean_id
|
528
|
+
|
529
|
+
# Generate fallback
|
530
|
+
return f"sandbox-{uuid.uuid4().hex[:8]}"
|
391
531
|
|
392
|
-
|
393
|
-
|
394
|
-
|
395
|
-
|
396
|
-
|
397
|
-
|
398
|
-
|
399
|
-
|
400
|
-
|
401
|
-
|
402
|
-
overwrite_artifact_id: str = None
|
403
|
-
) -> str:
|
404
|
-
"""Write content to a new file or overwrite existing."""
|
405
|
-
return await self._session.write_file(
|
406
|
-
content,
|
407
|
-
filename=filename,
|
408
|
-
mime=mime,
|
409
|
-
summary=summary,
|
410
|
-
session_id=session_id,
|
411
|
-
meta=meta,
|
412
|
-
encoding=encoding,
|
413
|
-
overwrite_artifact_id=overwrite_artifact_id
|
414
|
-
)
|
532
|
+
def _load_storage_provider(self, name: str) -> Callable[[], AsyncContextManager]:
|
533
|
+
"""Load storage provider."""
|
534
|
+
from importlib import import_module
|
535
|
+
|
536
|
+
try:
|
537
|
+
mod = import_module(f"chuk_artifacts.providers.{name}")
|
538
|
+
return mod.factory()
|
539
|
+
except ModuleNotFoundError as exc:
|
540
|
+
available = ["memory", "filesystem", "s3", "ibm_cos"]
|
541
|
+
raise ValueError(f"Unknown storage provider '{name}'. Available: {', '.join(available)}") from exc
|
415
542
|
|
416
|
-
|
417
|
-
|
418
|
-
|
419
|
-
|
420
|
-
|
421
|
-
|
422
|
-
|
423
|
-
|
424
|
-
|
425
|
-
directory_prefix,
|
426
|
-
limit
|
427
|
-
)
|
543
|
+
def _load_session_provider(self, name: str) -> Callable[[], AsyncContextManager]:
|
544
|
+
"""Load session provider."""
|
545
|
+
from importlib import import_module
|
546
|
+
|
547
|
+
try:
|
548
|
+
mod = import_module(f"chuk_sessions.providers.{name}")
|
549
|
+
return mod.factory()
|
550
|
+
except ModuleNotFoundError as exc:
|
551
|
+
raise ValueError(f"Unknown session provider '{name}'") from exc
|
428
552
|
|
429
553
|
# ─────────────────────────────────────────────────────────────────
|
430
554
|
# Resource management
|
431
555
|
# ─────────────────────────────────────────────────────────────────
|
432
556
|
|
433
557
|
async def close(self):
|
434
|
-
"""
|
558
|
+
"""Close the store."""
|
435
559
|
if not self._closed:
|
436
560
|
self._closed = True
|
437
561
|
logger.info("ArtifactStore closed")
|
@@ -440,42 +564,4 @@ class ArtifactStore:
|
|
440
564
|
return self
|
441
565
|
|
442
566
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
443
|
-
await self.close()
|
444
|
-
|
445
|
-
# ─────────────────────────────────────────────────────────────────
|
446
|
-
# Helper functions (still needed for provider loading)
|
447
|
-
# ─────────────────────────────────────────────────────────────────
|
448
|
-
|
449
|
-
def _load_storage_provider(self, name: str) -> Callable[[], AsyncContextManager]:
|
450
|
-
"""Load storage provider by name."""
|
451
|
-
from importlib import import_module
|
452
|
-
|
453
|
-
try:
|
454
|
-
mod = import_module(f"chuk_artifacts.providers.{name}")
|
455
|
-
except ModuleNotFoundError as exc:
|
456
|
-
available = ["memory", "filesystem", "s3", "ibm_cos", "ibm_cos_iam"]
|
457
|
-
raise ValueError(
|
458
|
-
f"Unknown storage provider '{name}'. "
|
459
|
-
f"Available providers: {', '.join(available)}"
|
460
|
-
) from exc
|
461
|
-
|
462
|
-
if not hasattr(mod, "factory"):
|
463
|
-
raise AttributeError(f"Storage provider '{name}' lacks factory()")
|
464
|
-
|
465
|
-
logger.info(f"Loaded storage provider: {name}")
|
466
|
-
return mod.factory()
|
467
|
-
|
468
|
-
def _load_session_provider(self, name: str) -> Callable[[], AsyncContextManager]:
|
469
|
-
"""Load session provider by name."""
|
470
|
-
from importlib import import_module
|
471
|
-
|
472
|
-
try:
|
473
|
-
mod = import_module(f"chuk_sessions.providers.{name}")
|
474
|
-
except ModuleNotFoundError as exc:
|
475
|
-
raise ValueError(f"Unknown session provider '{name}'") from exc
|
476
|
-
|
477
|
-
if not hasattr(mod, "factory"):
|
478
|
-
raise AttributeError(f"Session provider '{name}' lacks factory()")
|
479
|
-
|
480
|
-
logger.info(f"Loaded session provider: {name}")
|
481
|
-
return mod.factory()
|
567
|
+
await self.close()
|