monoco-toolkit 0.3.10__py3-none-any.whl → 0.3.12__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 (130) hide show
  1. monoco/__main__.py +8 -0
  2. monoco/core/artifacts/__init__.py +16 -0
  3. monoco/core/artifacts/manager.py +575 -0
  4. monoco/core/artifacts/models.py +161 -0
  5. monoco/core/automation/__init__.py +51 -0
  6. monoco/core/automation/config.py +338 -0
  7. monoco/core/automation/field_watcher.py +296 -0
  8. monoco/core/automation/handlers.py +723 -0
  9. monoco/core/config.py +31 -4
  10. monoco/core/executor/__init__.py +38 -0
  11. monoco/core/executor/agent_action.py +254 -0
  12. monoco/core/executor/git_action.py +303 -0
  13. monoco/core/executor/im_action.py +309 -0
  14. monoco/core/executor/pytest_action.py +218 -0
  15. monoco/core/git.py +38 -0
  16. monoco/core/hooks/context.py +74 -13
  17. monoco/core/ingestion/__init__.py +20 -0
  18. monoco/core/ingestion/discovery.py +248 -0
  19. monoco/core/ingestion/watcher.py +343 -0
  20. monoco/core/ingestion/worker.py +436 -0
  21. monoco/core/loader.py +633 -0
  22. monoco/core/registry.py +34 -25
  23. monoco/core/router/__init__.py +55 -0
  24. monoco/core/router/action.py +341 -0
  25. monoco/core/router/router.py +392 -0
  26. monoco/core/scheduler/__init__.py +63 -0
  27. monoco/core/scheduler/base.py +152 -0
  28. monoco/core/scheduler/engines.py +175 -0
  29. monoco/core/scheduler/events.py +171 -0
  30. monoco/core/scheduler/local.py +377 -0
  31. monoco/core/skills.py +119 -80
  32. monoco/core/watcher/__init__.py +57 -0
  33. monoco/core/watcher/base.py +365 -0
  34. monoco/core/watcher/dropzone.py +152 -0
  35. monoco/core/watcher/issue.py +303 -0
  36. monoco/core/watcher/memo.py +200 -0
  37. monoco/core/watcher/task.py +238 -0
  38. monoco/daemon/app.py +77 -1
  39. monoco/daemon/commands.py +10 -0
  40. monoco/daemon/events.py +34 -0
  41. monoco/daemon/mailroom_service.py +196 -0
  42. monoco/daemon/models.py +1 -0
  43. monoco/daemon/scheduler.py +207 -0
  44. monoco/daemon/services.py +27 -58
  45. monoco/daemon/triggers.py +55 -0
  46. monoco/features/agent/__init__.py +25 -7
  47. monoco/features/agent/adapter.py +17 -7
  48. monoco/features/agent/cli.py +91 -57
  49. monoco/features/agent/engines.py +31 -170
  50. monoco/{core/resources/en/skills/monoco_core → features/agent/resources/en/skills/monoco_atom_core}/SKILL.md +2 -2
  51. monoco/features/agent/resources/en/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
  52. monoco/features/agent/resources/en/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
  53. monoco/features/agent/resources/en/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
  54. monoco/features/agent/resources/en/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
  55. monoco/features/agent/resources/{roles/role-engineer.yaml → zh/roles/monoco_role_engineer.yaml} +3 -3
  56. monoco/features/agent/resources/{roles/role-manager.yaml → zh/roles/monoco_role_manager.yaml} +8 -8
  57. monoco/features/agent/resources/{roles/role-planner.yaml → zh/roles/monoco_role_planner.yaml} +8 -8
  58. monoco/features/agent/resources/{roles/role-reviewer.yaml → zh/roles/monoco_role_reviewer.yaml} +8 -8
  59. monoco/{core/resources/zh/skills/monoco_core → features/agent/resources/zh/skills/monoco_atom_core}/SKILL.md +2 -2
  60. monoco/features/agent/resources/zh/skills/{flow_engineer → monoco_workflow_agent_engineer}/SKILL.md +2 -2
  61. monoco/features/agent/resources/zh/skills/{flow_manager → monoco_workflow_agent_manager}/SKILL.md +2 -2
  62. monoco/features/agent/resources/zh/skills/{flow_planner → monoco_workflow_agent_planner}/SKILL.md +2 -2
  63. monoco/features/agent/resources/zh/skills/{flow_reviewer → monoco_workflow_agent_reviewer}/SKILL.md +2 -2
  64. monoco/features/agent/worker.py +1 -1
  65. monoco/features/artifact/__init__.py +0 -0
  66. monoco/features/artifact/adapter.py +33 -0
  67. monoco/features/artifact/resources/zh/AGENTS.md +14 -0
  68. monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +278 -0
  69. monoco/features/glossary/adapter.py +18 -7
  70. monoco/features/glossary/resources/en/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
  71. monoco/features/glossary/resources/zh/skills/{monoco_glossary → monoco_atom_glossary}/SKILL.md +2 -2
  72. monoco/features/hooks/__init__.py +11 -0
  73. monoco/features/hooks/adapter.py +67 -0
  74. monoco/features/hooks/commands.py +309 -0
  75. monoco/features/hooks/core.py +441 -0
  76. monoco/features/hooks/resources/ADDING_HOOKS.md +234 -0
  77. monoco/features/i18n/adapter.py +18 -5
  78. monoco/features/i18n/core.py +482 -17
  79. monoco/features/i18n/resources/en/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
  80. monoco/features/i18n/resources/en/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
  81. monoco/features/i18n/resources/zh/skills/{monoco_i18n → monoco_atom_i18n}/SKILL.md +2 -2
  82. monoco/features/i18n/resources/zh/skills/{i18n_scan_workflow → monoco_workflow_i18n_scan}/SKILL.md +2 -2
  83. monoco/features/issue/adapter.py +19 -6
  84. monoco/features/issue/commands.py +352 -20
  85. monoco/features/issue/core.py +475 -16
  86. monoco/features/issue/engine/machine.py +114 -4
  87. monoco/features/issue/linter.py +60 -5
  88. monoco/features/issue/models.py +2 -2
  89. monoco/features/issue/resources/en/AGENTS.md +109 -0
  90. monoco/features/issue/resources/en/skills/{monoco_issue → monoco_atom_issue}/SKILL.md +2 -2
  91. monoco/features/issue/resources/en/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
  92. monoco/features/issue/resources/en/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
  93. monoco/features/issue/resources/en/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
  94. monoco/features/issue/resources/en/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
  95. monoco/features/issue/resources/hooks/post-checkout.sh +39 -0
  96. monoco/features/issue/resources/hooks/pre-commit.sh +41 -0
  97. monoco/features/issue/resources/hooks/pre-push.sh +35 -0
  98. monoco/features/issue/resources/zh/AGENTS.md +109 -0
  99. monoco/features/issue/resources/zh/skills/{monoco_issue → monoco_atom_issue_lifecycle}/SKILL.md +2 -2
  100. monoco/features/issue/resources/zh/skills/{issue_create_workflow → monoco_workflow_issue_creation}/SKILL.md +2 -2
  101. monoco/features/issue/resources/zh/skills/{issue_develop_workflow → monoco_workflow_issue_development}/SKILL.md +2 -2
  102. monoco/features/issue/resources/zh/skills/{issue_lifecycle_workflow → monoco_workflow_issue_management}/SKILL.md +2 -2
  103. monoco/features/issue/resources/zh/skills/{issue_refine_workflow → monoco_workflow_issue_refinement}/SKILL.md +2 -2
  104. monoco/features/issue/validator.py +101 -1
  105. monoco/features/memo/adapter.py +21 -8
  106. monoco/features/memo/cli.py +103 -10
  107. monoco/features/memo/core.py +178 -92
  108. monoco/features/memo/models.py +53 -0
  109. monoco/features/memo/resources/en/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
  110. monoco/features/memo/resources/en/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
  111. monoco/features/memo/resources/zh/skills/{monoco_memo → monoco_atom_memo}/SKILL.md +2 -2
  112. monoco/features/memo/resources/zh/skills/{note_processing_workflow → monoco_workflow_note_processing}/SKILL.md +2 -2
  113. monoco/features/spike/adapter.py +18 -5
  114. monoco/features/spike/commands.py +5 -3
  115. monoco/features/spike/resources/en/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
  116. monoco/features/spike/resources/en/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
  117. monoco/features/spike/resources/zh/skills/{monoco_spike → monoco_atom_spike}/SKILL.md +2 -2
  118. monoco/features/spike/resources/zh/skills/{research_workflow → monoco_workflow_research}/SKILL.md +2 -2
  119. monoco/main.py +38 -1
  120. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/METADATA +7 -1
  121. monoco_toolkit-0.3.12.dist-info/RECORD +202 -0
  122. monoco/features/agent/apoptosis.py +0 -44
  123. monoco/features/agent/manager.py +0 -91
  124. monoco/features/agent/session.py +0 -121
  125. monoco_toolkit-0.3.10.dist-info/RECORD +0 -156
  126. /monoco/{core → features/agent}/resources/en/AGENTS.md +0 -0
  127. /monoco/{core → features/agent}/resources/zh/AGENTS.md +0 -0
  128. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/WHEEL +0 -0
  129. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/entry_points.txt +0 -0
  130. {monoco_toolkit-0.3.10.dist-info → monoco_toolkit-0.3.12.dist-info}/licenses/LICENSE +0 -0
monoco/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """
2
+ Entry point for `python -m monoco`.
3
+ """
4
+
5
+ from monoco.main import app
6
+
7
+ if __name__ == "__main__":
8
+ app()
@@ -0,0 +1,16 @@
1
+ """
2
+ Monoco Artifact System - CAS Storage and Metadata Registry
3
+
4
+ Provides content-addressable storage for multi-modal assets with project-local
5
+ metadata tracking via manifest.jsonl.
6
+ """
7
+
8
+ from .models import ArtifactMetadata, ArtifactSourceType, ArtifactStatus
9
+ from .manager import ArtifactManager
10
+
11
+ __all__ = [
12
+ "ArtifactMetadata",
13
+ "ArtifactSourceType",
14
+ "ArtifactStatus",
15
+ "ArtifactManager",
16
+ ]
@@ -0,0 +1,575 @@
1
+ """
2
+ ArtifactManager - Core management class for Monoco Artifact System.
3
+
4
+ Implements CRUD operations, CAS (Content-Addressable Storage) management,
5
+ and manifest.jsonl registry operations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import tempfile
14
+ import threading
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+ from typing import Any, Optional
19
+
20
+ from .models import (
21
+ ArtifactMetadata,
22
+ ArtifactSourceType,
23
+ ArtifactStatus,
24
+ compute_content_hash,
25
+ compute_file_hash,
26
+ )
27
+
28
+
29
+ class ArtifactManager:
30
+ """
31
+ Manages artifacts with CAS storage and manifest-based metadata registry.
32
+
33
+ Implements a hybrid storage architecture:
34
+ - Global storage pool: ~/.monoco/artifacts/ (physical storage)
35
+ - Project-local registry: ./.monoco/artifacts/manifest.jsonl
36
+
37
+ CAS (Content-Addressable Storage):
38
+ - Files are stored by their SHA256 hash
39
+ - Path structure: {global_store}/{hash[:2]}/{hash[2:4]}/{hash}
40
+ - Automatic deduplication via hash-based addressing
41
+ """
42
+
43
+ def __init__(
44
+ self,
45
+ project_dir: Path,
46
+ global_store: Optional[Path] = None,
47
+ ):
48
+ """
49
+ Initialize ArtifactManager.
50
+
51
+ Args:
52
+ project_dir: Root directory of the project (for local manifest)
53
+ global_store: Path to global artifact store (default: ~/.monoco/artifacts)
54
+ """
55
+ self.project_dir = Path(project_dir).resolve()
56
+ self.global_store = (
57
+ Path(global_store).expanduser().resolve()
58
+ if global_store
59
+ else Path.home() / ".monoco" / "artifacts"
60
+ )
61
+
62
+ # Project-local artifact directory
63
+ self.local_artifacts_dir = self.project_dir / ".monoco" / "artifacts"
64
+ self.manifest_path = self.local_artifacts_dir / "manifest.jsonl"
65
+
66
+ # In-memory cache of metadata (artifact_id -> metadata)
67
+ self._metadata_cache: dict[str, ArtifactMetadata] = {}
68
+ self._lock = threading.RLock()
69
+
70
+ # Ensure directories exist
71
+ self._ensure_directories()
72
+
73
+ # Load existing manifest
74
+ self._load_manifest()
75
+
76
+ def _ensure_directories(self) -> None:
77
+ """Create necessary directories if they don't exist."""
78
+ self.global_store.mkdir(parents=True, exist_ok=True)
79
+ self.local_artifacts_dir.mkdir(parents=True, exist_ok=True)
80
+
81
+ def _load_manifest(self) -> None:
82
+ """Load manifest.jsonl into memory cache."""
83
+ if not self.manifest_path.exists():
84
+ return
85
+
86
+ with self._lock:
87
+ with open(self.manifest_path, "r", encoding="utf-8") as f:
88
+ for line in f:
89
+ line = line.strip()
90
+ if not line:
91
+ continue
92
+ try:
93
+ metadata = ArtifactMetadata.from_jsonl_line(line)
94
+ self._metadata_cache[metadata.artifact_id] = metadata
95
+ except (json.JSONDecodeError, ValueError):
96
+ # Skip corrupted lines, could log warning
97
+ continue
98
+
99
+ def _atomic_append_manifest(self, metadata: ArtifactMetadata) -> None:
100
+ """Atomically append a metadata entry to manifest.jsonl."""
101
+ with self._lock:
102
+ # Write to temp file first, then rename for atomicity
103
+ fd, temp_path = tempfile.mkstemp(
104
+ dir=self.local_artifacts_dir, suffix=".tmp"
105
+ )
106
+ try:
107
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
108
+ f.write(metadata.to_jsonl_line())
109
+
110
+ # Append temp content to manifest atomically
111
+ with open(temp_path, "r", encoding="utf-8") as src:
112
+ with open(self.manifest_path, "a", encoding="utf-8") as dst:
113
+ dst.write(src.read())
114
+ finally:
115
+ if os.path.exists(temp_path):
116
+ os.unlink(temp_path)
117
+
118
+ def _rewrite_manifest(self) -> None:
119
+ """Rewrite entire manifest from cache (for deletes/updates)."""
120
+ with self._lock:
121
+ fd, temp_path = tempfile.mkstemp(
122
+ dir=self.local_artifacts_dir, suffix=".tmp"
123
+ )
124
+ try:
125
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
126
+ for metadata in self._metadata_cache.values():
127
+ # Keep all records including DELETED for audit trail
128
+ f.write(metadata.to_jsonl_line())
129
+
130
+ # Atomic rename
131
+ os.replace(temp_path, self.manifest_path)
132
+ except Exception:
133
+ if os.path.exists(temp_path):
134
+ os.unlink(temp_path)
135
+ raise
136
+
137
+ def _get_cas_path(self, content_hash: str) -> Path:
138
+ """Get the CAS storage path for a content hash."""
139
+ prefix1 = content_hash[:2]
140
+ prefix2 = content_hash[2:4]
141
+ return self.global_store / prefix1 / prefix2 / content_hash
142
+
143
+ def _store_in_cas(self, content: bytes, content_hash: str) -> Path:
144
+ """
145
+ Store content in CAS. If content already exists, skip writing.
146
+
147
+ Returns:
148
+ Path to the stored file in CAS
149
+ """
150
+ cas_path = self._get_cas_path(content_hash)
151
+
152
+ if cas_path.exists():
153
+ # Content already stored (deduplication)
154
+ return cas_path
155
+
156
+ # Ensure parent directories exist
157
+ cas_path.parent.mkdir(parents=True, exist_ok=True)
158
+
159
+ # Write atomically using temp file
160
+ fd, temp_path = tempfile.mkstemp(dir=cas_path.parent, suffix=".tmp")
161
+ try:
162
+ with os.fdopen(fd, "wb") as f:
163
+ f.write(content)
164
+ os.replace(temp_path, cas_path)
165
+ except Exception:
166
+ if os.path.exists(temp_path):
167
+ os.unlink(temp_path)
168
+ raise
169
+
170
+ return cas_path
171
+
172
+ def store(
173
+ self,
174
+ content: bytes,
175
+ source_type: ArtifactSourceType = ArtifactSourceType.GENERATED,
176
+ content_type: str = "application/octet-stream",
177
+ original_filename: Optional[str] = None,
178
+ expires_at: Optional[datetime] = None,
179
+ tags: Optional[list[str]] = None,
180
+ metadata: Optional[dict[str, Any]] = None,
181
+ source_url: Optional[str] = None,
182
+ parent_artifact_id: Optional[str] = None,
183
+ ) -> ArtifactMetadata:
184
+ """
185
+ Store a new artifact with CAS deduplication.
186
+
187
+ Args:
188
+ content: Raw bytes of the artifact content
189
+ source_type: How the artifact was created
190
+ content_type: MIME type of the content
191
+ original_filename: Original filename if uploaded
192
+ expires_at: Optional expiration timestamp
193
+ tags: User-defined tags
194
+ metadata: Additional metadata key-value pairs
195
+ source_url: Source URL if imported
196
+ parent_artifact_id: Parent artifact ID if derived
197
+
198
+ Returns:
199
+ ArtifactMetadata for the stored artifact
200
+ """
201
+ # Compute content hash for CAS
202
+ content_hash = compute_content_hash(content)
203
+
204
+ # Store in CAS (deduplication happens automatically)
205
+ cas_path = self._store_in_cas(content, content_hash)
206
+
207
+ # Create metadata
208
+ artifact_meta = ArtifactMetadata(
209
+ artifact_id=str(uuid.uuid4()),
210
+ content_hash=content_hash,
211
+ source_type=source_type,
212
+ status=ArtifactStatus.ACTIVE,
213
+ created_at=datetime.now(timezone.utc),
214
+ updated_at=datetime.now(timezone.utc),
215
+ expires_at=expires_at,
216
+ content_type=content_type,
217
+ size_bytes=len(content),
218
+ original_filename=original_filename,
219
+ source_url=source_url,
220
+ parent_artifact_id=parent_artifact_id,
221
+ tags=tags or [],
222
+ metadata=metadata or {},
223
+ )
224
+
225
+ # Update cache and manifest
226
+ with self._lock:
227
+ self._metadata_cache[artifact_meta.artifact_id] = artifact_meta
228
+ self._atomic_append_manifest(artifact_meta)
229
+
230
+ return artifact_meta
231
+
232
+ def store_file(
233
+ self,
234
+ file_path: Path,
235
+ source_type: ArtifactSourceType = ArtifactSourceType.UPLOADED,
236
+ content_type: Optional[str] = None,
237
+ expires_at: Optional[datetime] = None,
238
+ tags: Optional[list[str]] = None,
239
+ metadata: Optional[dict[str, Any]] = None,
240
+ ) -> ArtifactMetadata:
241
+ """
242
+ Store a file as an artifact.
243
+
244
+ Args:
245
+ file_path: Path to the file to store
246
+ source_type: How the artifact was created
247
+ content_type: MIME type (auto-detected if not provided)
248
+ expires_at: Optional expiration timestamp
249
+ tags: User-defined tags
250
+ metadata: Additional metadata
251
+
252
+ Returns:
253
+ ArtifactMetadata for the stored artifact
254
+ """
255
+ file_path = Path(file_path)
256
+ content = file_path.read_bytes()
257
+
258
+ if content_type is None:
259
+ content_type = self._detect_content_type(file_path)
260
+
261
+ return self.store(
262
+ content=content,
263
+ source_type=source_type,
264
+ content_type=content_type,
265
+ original_filename=file_path.name,
266
+ expires_at=expires_at,
267
+ tags=tags,
268
+ metadata=metadata,
269
+ )
270
+
271
+ def _detect_content_type(self, file_path: Path) -> str:
272
+ """Detect MIME type from file extension."""
273
+ suffix = file_path.suffix.lower()
274
+ mime_types = {
275
+ ".txt": "text/plain",
276
+ ".md": "text/markdown",
277
+ ".json": "application/json",
278
+ ".jsonl": "application/jsonlines",
279
+ ".yaml": "application/yaml",
280
+ ".yml": "application/yaml",
281
+ ".png": "image/png",
282
+ ".jpg": "image/jpeg",
283
+ ".jpeg": "image/jpeg",
284
+ ".gif": "image/gif",
285
+ ".svg": "image/svg+xml",
286
+ ".pdf": "application/pdf",
287
+ ".html": "text/html",
288
+ ".htm": "text/html",
289
+ ".css": "text/css",
290
+ ".js": "application/javascript",
291
+ ".py": "text/x-python",
292
+ ".zip": "application/zip",
293
+ }
294
+ return mime_types.get(suffix, "application/octet-stream")
295
+
296
+ def get(self, artifact_id: str) -> Optional[ArtifactMetadata]:
297
+ """
298
+ Get artifact metadata by ID.
299
+
300
+ Args:
301
+ artifact_id: The unique artifact identifier
302
+
303
+ Returns:
304
+ ArtifactMetadata if found and active, None otherwise
305
+ (returns None for DELETED or EXPIRED artifacts)
306
+ """
307
+ with self._lock:
308
+ metadata = self._metadata_cache.get(artifact_id)
309
+ if metadata is None:
310
+ return None
311
+ if metadata.status in (ArtifactStatus.DELETED, ArtifactStatus.EXPIRED):
312
+ return None
313
+ return metadata
314
+
315
+ def get_content(self, artifact_id: str) -> Optional[bytes]:
316
+ """
317
+ Get artifact content by ID.
318
+
319
+ Args:
320
+ artifact_id: The unique artifact identifier
321
+
322
+ Returns:
323
+ Content bytes if found, None otherwise
324
+ """
325
+ metadata = self.get(artifact_id)
326
+ if metadata is None:
327
+ return None
328
+
329
+ cas_path = self._get_cas_path(metadata.content_hash)
330
+ if not cas_path.exists():
331
+ return None
332
+
333
+ return cas_path.read_bytes()
334
+
335
+ def get_content_path(self, artifact_id: str) -> Optional[Path]:
336
+ """
337
+ Get the filesystem path to artifact content (read-only access).
338
+
339
+ Args:
340
+ artifact_id: The unique artifact identifier
341
+
342
+ Returns:
343
+ Path to content if exists, None otherwise
344
+ """
345
+ metadata = self.get(artifact_id)
346
+ if metadata is None:
347
+ return None
348
+
349
+ cas_path = self._get_cas_path(metadata.content_hash)
350
+ if cas_path.exists():
351
+ return cas_path
352
+ return None
353
+
354
+ def list(
355
+ self,
356
+ status: Optional[ArtifactStatus] = None,
357
+ source_type: Optional[ArtifactSourceType] = None,
358
+ tags: Optional[list[str]] = None,
359
+ include_expired: bool = False,
360
+ ) -> list[ArtifactMetadata]:
361
+ """
362
+ List artifacts with optional filtering.
363
+
364
+ Args:
365
+ status: Filter by status
366
+ source_type: Filter by source type
367
+ tags: Filter by tags (must have all specified tags)
368
+ include_expired: Include expired artifacts
369
+
370
+ Returns:
371
+ List of matching ArtifactMetadata
372
+ """
373
+ with self._lock:
374
+ results = []
375
+ for metadata in self._metadata_cache.values():
376
+ # Skip deleted
377
+ if metadata.status == ArtifactStatus.DELETED:
378
+ continue
379
+
380
+ # Apply filters
381
+ if status is not None and metadata.status != status:
382
+ continue
383
+ if source_type is not None and metadata.source_type != source_type:
384
+ continue
385
+ if tags is not None and not all(tag in metadata.tags for tag in tags):
386
+ continue
387
+ if not include_expired and metadata.is_expired:
388
+ continue
389
+
390
+ results.append(metadata)
391
+
392
+ return sorted(results, key=lambda m: m.created_at, reverse=True)
393
+
394
+ def update(
395
+ self,
396
+ artifact_id: str,
397
+ tags: Optional[list[str]] = None,
398
+ metadata: Optional[dict[str, Any]] = None,
399
+ expires_at: Optional[datetime] = None,
400
+ status: Optional[ArtifactStatus] = None,
401
+ ) -> Optional[ArtifactMetadata]:
402
+ """
403
+ Update artifact metadata (not content - content is immutable in CAS).
404
+
405
+ Args:
406
+ artifact_id: The artifact to update
407
+ tags: New tags (replaces existing)
408
+ metadata: Additional metadata to merge
409
+ expires_at: New expiration timestamp
410
+ status: New status
411
+
412
+ Returns:
413
+ Updated ArtifactMetadata if found, None otherwise
414
+ """
415
+ with self._lock:
416
+ existing = self._metadata_cache.get(artifact_id)
417
+ if existing is None or existing.status == ArtifactStatus.DELETED:
418
+ return None
419
+
420
+ # Update fields
421
+ if tags is not None:
422
+ existing.tags = tags
423
+ if metadata is not None:
424
+ existing.metadata.update(metadata)
425
+ if expires_at is not None:
426
+ existing.expires_at = expires_at
427
+ if status is not None:
428
+ existing.status = status
429
+
430
+ existing.updated_at = datetime.now(timezone.utc)
431
+
432
+ # Rewrite manifest
433
+ self._rewrite_manifest()
434
+
435
+ return existing
436
+
437
+ def delete(self, artifact_id: str, permanent: bool = False) -> bool:
438
+ """
439
+ Delete (or mark as deleted) an artifact.
440
+
441
+ Args:
442
+ artifact_id: The artifact to delete
443
+ permanent: If True, permanently remove from CAS and manifest
444
+
445
+ Returns:
446
+ True if deleted, False if not found
447
+ """
448
+ with self._lock:
449
+ metadata = self._metadata_cache.get(artifact_id)
450
+ if metadata is None or metadata.status == ArtifactStatus.DELETED:
451
+ return False
452
+
453
+ if permanent:
454
+ # Remove from cache and rewrite manifest
455
+ del self._metadata_cache[artifact_id]
456
+ self._rewrite_manifest()
457
+
458
+ # Remove from CAS (only if no other artifacts reference this hash)
459
+ self._cleanup_cas_if_orphaned(metadata.content_hash)
460
+ else:
461
+ # Soft delete
462
+ metadata.status = ArtifactStatus.DELETED
463
+ metadata.updated_at = datetime.now(timezone.utc)
464
+ self._rewrite_manifest()
465
+
466
+ return True
467
+
468
+ def _cleanup_cas_if_orphaned(self, content_hash: str) -> None:
469
+ """Remove content from CAS if no other artifacts reference it."""
470
+ # Check if any other artifact uses this hash
471
+ for meta in self._metadata_cache.values():
472
+ if meta.content_hash == content_hash and meta.status != ArtifactStatus.DELETED:
473
+ return # Still referenced
474
+
475
+ # Safe to delete from CAS
476
+ cas_path = self._get_cas_path(content_hash)
477
+ if cas_path.exists():
478
+ cas_path.unlink()
479
+
480
+ # Cleanup empty directories
481
+ try:
482
+ cas_path.parent.rmdir() # Remove hash[2:4] dir if empty
483
+ cas_path.parent.parent.rmdir() # Remove hash[:2] dir if empty
484
+ except OSError:
485
+ pass # Directory not empty
486
+
487
+ def cleanup_expired(self) -> list[str]:
488
+ """
489
+ Remove all expired artifacts (soft delete).
490
+
491
+ Returns:
492
+ List of artifact IDs that were cleaned up
493
+ """
494
+ cleaned = []
495
+ with self._lock:
496
+ for metadata in self._metadata_cache.values():
497
+ if metadata.is_expired and metadata.status == ArtifactStatus.ACTIVE:
498
+ metadata.status = ArtifactStatus.EXPIRED
499
+ metadata.updated_at = datetime.now(timezone.utc)
500
+ cleaned.append(metadata.artifact_id)
501
+
502
+ if cleaned:
503
+ self._rewrite_manifest()
504
+
505
+ return cleaned
506
+
507
+ def get_stats(self) -> dict[str, Any]:
508
+ """
509
+ Get statistics about the artifact store.
510
+
511
+ Returns:
512
+ Dictionary with statistics
513
+ """
514
+ with self._lock:
515
+ total = len(self._metadata_cache)
516
+ active = sum(
517
+ 1
518
+ for m in self._metadata_cache.values()
519
+ if m.status == ArtifactStatus.ACTIVE
520
+ )
521
+ expired = sum(
522
+ 1
523
+ for m in self._metadata_cache.values()
524
+ if m.status == ArtifactStatus.EXPIRED
525
+ )
526
+ archived = sum(
527
+ 1
528
+ for m in self._metadata_cache.values()
529
+ if m.status == ArtifactStatus.ARCHIVED
530
+ )
531
+ total_size = sum(
532
+ m.size_bytes
533
+ for m in self._metadata_cache.values()
534
+ if m.status != ArtifactStatus.DELETED
535
+ )
536
+
537
+ return {
538
+ "total_artifacts": total,
539
+ "active": active,
540
+ "expired": expired,
541
+ "archived": archived,
542
+ "total_size_bytes": total_size,
543
+ "global_store_path": str(self.global_store),
544
+ "manifest_path": str(self.manifest_path),
545
+ }
546
+
547
+ def create_symlink(self, artifact_id: str, link_path: Path) -> bool:
548
+ """
549
+ Create a symlink from link_path to the artifact content.
550
+
551
+ Args:
552
+ artifact_id: The artifact to link to
553
+ link_path: Where to create the symlink
554
+
555
+ Returns:
556
+ True if successful, False otherwise
557
+ """
558
+ content_path = self.get_content_path(artifact_id)
559
+ if content_path is None:
560
+ return False
561
+
562
+ link_path = Path(link_path)
563
+ link_path.parent.mkdir(parents=True, exist_ok=True)
564
+
565
+ # Remove existing link if present
566
+ if link_path.exists() or link_path.is_symlink():
567
+ link_path.unlink()
568
+
569
+ # Create relative symlink for portability
570
+ try:
571
+ rel_target = os.path.relpath(content_path, link_path.parent)
572
+ link_path.symlink_to(rel_target)
573
+ return True
574
+ except OSError:
575
+ return False