monoco-toolkit 0.3.9__py3-none-any.whl → 0.3.11__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.
- monoco/__main__.py +8 -0
- monoco/core/artifacts/__init__.py +16 -0
- monoco/core/artifacts/manager.py +575 -0
- monoco/core/artifacts/models.py +161 -0
- monoco/core/config.py +38 -4
- monoco/core/git.py +23 -0
- monoco/core/hooks/builtin/git_cleanup.py +1 -1
- monoco/core/ingestion/__init__.py +20 -0
- monoco/core/ingestion/discovery.py +248 -0
- monoco/core/ingestion/watcher.py +343 -0
- monoco/core/ingestion/worker.py +436 -0
- monoco/core/injection.py +63 -29
- monoco/core/integrations.py +2 -2
- monoco/core/loader.py +633 -0
- monoco/core/output.py +5 -5
- monoco/core/registry.py +34 -19
- monoco/core/resource/__init__.py +5 -0
- monoco/core/resource/finder.py +98 -0
- monoco/core/resource/manager.py +91 -0
- monoco/core/resource/models.py +35 -0
- monoco/core/skill_framework.py +292 -0
- monoco/core/skills.py +524 -385
- monoco/core/sync.py +73 -1
- monoco/core/workflow_converter.py +420 -0
- monoco/daemon/app.py +77 -1
- monoco/daemon/commands.py +10 -0
- monoco/daemon/mailroom_service.py +196 -0
- monoco/daemon/models.py +1 -0
- monoco/daemon/scheduler.py +236 -0
- monoco/daemon/services.py +185 -0
- monoco/daemon/triggers.py +55 -0
- monoco/features/agent/__init__.py +2 -2
- monoco/features/agent/adapter.py +41 -0
- monoco/features/agent/apoptosis.py +44 -0
- monoco/features/agent/cli.py +101 -144
- monoco/features/agent/config.py +35 -21
- monoco/features/agent/defaults.py +6 -49
- monoco/features/agent/engines.py +32 -6
- monoco/features/agent/manager.py +47 -6
- monoco/features/agent/models.py +2 -2
- monoco/features/agent/resources/atoms/atom-code-dev.yaml +61 -0
- monoco/features/agent/resources/atoms/atom-issue-lifecycle.yaml +73 -0
- monoco/features/agent/resources/atoms/atom-knowledge.yaml +55 -0
- monoco/features/agent/resources/atoms/atom-review.yaml +60 -0
- monoco/{core/resources/en → features/agent/resources/en/skills/monoco_atom_core}/SKILL.md +3 -1
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_engineer/SKILL.md +94 -0
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_manager/SKILL.md +93 -0
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_planner/SKILL.md +85 -0
- monoco/features/agent/resources/en/skills/monoco_workflow_agent_reviewer/SKILL.md +114 -0
- monoco/features/agent/resources/workflows/workflow-dev.yaml +83 -0
- monoco/features/agent/resources/workflows/workflow-issue-create.yaml +72 -0
- monoco/features/agent/resources/workflows/workflow-review.yaml +94 -0
- monoco/features/agent/resources/zh/roles/monoco_role_engineer.yaml +49 -0
- monoco/features/agent/resources/zh/roles/monoco_role_manager.yaml +46 -0
- monoco/features/agent/resources/zh/roles/monoco_role_planner.yaml +46 -0
- monoco/features/agent/resources/zh/roles/monoco_role_reviewer.yaml +47 -0
- monoco/{core/resources/zh → features/agent/resources/zh/skills/monoco_atom_core}/SKILL.md +3 -1
- monoco/features/agent/resources/{skills/flow_engineer → zh/skills/monoco_workflow_agent_engineer}/SKILL.md +2 -2
- monoco/features/agent/resources/{skills/flow_manager → zh/skills/monoco_workflow_agent_manager}/SKILL.md +2 -2
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_planner/SKILL.md +259 -0
- monoco/features/agent/resources/zh/skills/monoco_workflow_agent_reviewer/SKILL.md +137 -0
- monoco/features/agent/session.py +59 -11
- monoco/features/agent/worker.py +38 -2
- monoco/features/artifact/__init__.py +0 -0
- monoco/features/artifact/adapter.py +33 -0
- monoco/features/artifact/resources/zh/AGENTS.md +14 -0
- monoco/features/artifact/resources/zh/skills/monoco_atom_artifact/SKILL.md +278 -0
- monoco/features/glossary/__init__.py +0 -0
- monoco/features/glossary/adapter.py +42 -0
- monoco/features/glossary/config.py +5 -0
- monoco/features/glossary/resources/en/AGENTS.md +29 -0
- monoco/features/glossary/resources/en/skills/monoco_atom_glossary/SKILL.md +35 -0
- monoco/features/glossary/resources/zh/AGENTS.md +29 -0
- monoco/features/glossary/resources/zh/skills/monoco_atom_glossary/SKILL.md +35 -0
- monoco/features/hooks/__init__.py +11 -0
- monoco/features/hooks/adapter.py +67 -0
- monoco/features/hooks/commands.py +309 -0
- monoco/features/hooks/core.py +441 -0
- monoco/features/hooks/resources/ADDING_HOOKS.md +234 -0
- monoco/features/i18n/adapter.py +18 -5
- monoco/features/i18n/core.py +482 -17
- monoco/features/i18n/resources/en/{SKILL.md → skills/monoco_atom_i18n/SKILL.md} +3 -1
- monoco/features/i18n/resources/en/skills/monoco_workflow_i18n_scan/SKILL.md +105 -0
- monoco/features/i18n/resources/zh/{SKILL.md → skills/monoco_atom_i18n/SKILL.md} +3 -1
- monoco/features/i18n/resources/{skills/i18n_scan_workflow → zh/skills/monoco_workflow_i18n_scan}/SKILL.md +2 -2
- monoco/features/issue/adapter.py +19 -6
- monoco/features/issue/commands.py +281 -7
- monoco/features/issue/core.py +272 -19
- monoco/features/issue/engine/machine.py +118 -5
- monoco/features/issue/linter.py +60 -5
- monoco/features/issue/models.py +3 -2
- monoco/features/issue/resources/en/AGENTS.md +109 -0
- monoco/features/issue/resources/en/{SKILL.md → skills/monoco_atom_issue/SKILL.md} +3 -1
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_creation/SKILL.md +167 -0
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_development/SKILL.md +224 -0
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_management/SKILL.md +159 -0
- monoco/features/issue/resources/en/skills/monoco_workflow_issue_refinement/SKILL.md +203 -0
- monoco/features/issue/resources/hooks/post-checkout.sh +39 -0
- monoco/features/issue/resources/hooks/pre-commit.sh +41 -0
- monoco/features/issue/resources/hooks/pre-push.sh +35 -0
- monoco/features/issue/resources/zh/AGENTS.md +109 -0
- monoco/features/issue/resources/zh/{SKILL.md → skills/monoco_atom_issue_lifecycle/SKILL.md} +3 -1
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_creation/SKILL.md +167 -0
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_development/SKILL.md +224 -0
- monoco/features/issue/resources/{skills/issue_lifecycle_workflow → zh/skills/monoco_workflow_issue_management}/SKILL.md +2 -2
- monoco/features/issue/resources/zh/skills/monoco_workflow_issue_refinement/SKILL.md +203 -0
- monoco/features/issue/validator.py +101 -1
- monoco/features/memo/adapter.py +21 -8
- monoco/features/memo/cli.py +103 -10
- monoco/features/memo/core.py +178 -92
- monoco/features/memo/models.py +53 -0
- monoco/features/memo/resources/en/skills/monoco_atom_memo/SKILL.md +77 -0
- monoco/features/memo/resources/en/skills/monoco_workflow_note_processing/SKILL.md +140 -0
- monoco/features/memo/resources/zh/{SKILL.md → skills/monoco_atom_memo/SKILL.md} +3 -1
- monoco/features/memo/resources/{skills/note_processing_workflow → zh/skills/monoco_workflow_note_processing}/SKILL.md +2 -2
- monoco/features/spike/adapter.py +18 -5
- monoco/features/spike/resources/en/{SKILL.md → skills/monoco_atom_spike/SKILL.md} +3 -1
- monoco/features/spike/resources/en/skills/monoco_workflow_research/SKILL.md +121 -0
- monoco/features/spike/resources/zh/{SKILL.md → skills/monoco_atom_spike/SKILL.md} +3 -1
- monoco/features/spike/resources/{skills/research_workflow → zh/skills/monoco_workflow_research}/SKILL.md +2 -2
- monoco/main.py +38 -1
- monoco_toolkit-0.3.11.dist-info/METADATA +130 -0
- monoco_toolkit-0.3.11.dist-info/RECORD +181 -0
- monoco/features/agent/reliability.py +0 -106
- monoco/features/agent/resources/skills/flow_reviewer/SKILL.md +0 -114
- monoco_toolkit-0.3.9.dist-info/METADATA +0 -127
- monoco_toolkit-0.3.9.dist-info/RECORD +0 -115
- /monoco/{core → features/agent}/resources/en/AGENTS.md +0 -0
- /monoco/{core → features/agent}/resources/zh/AGENTS.md +0 -0
- {monoco_toolkit-0.3.9.dist-info → monoco_toolkit-0.3.11.dist-info}/WHEEL +0 -0
- {monoco_toolkit-0.3.9.dist-info → monoco_toolkit-0.3.11.dist-info}/entry_points.txt +0 -0
- {monoco_toolkit-0.3.9.dist-info → monoco_toolkit-0.3.11.dist-info}/licenses/LICENSE +0 -0
monoco/__main__.py
ADDED
|
@@ -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
|