shotgun-sh 0.1.0.dev11__py3-none-any.whl → 0.1.0.dev13__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.
Potentially problematic release.
This version of shotgun-sh might be problematic. Click here for more details.
- shotgun/agents/common.py +94 -79
- shotgun/agents/config/constants.py +18 -0
- shotgun/agents/config/manager.py +68 -16
- shotgun/agents/config/provider.py +11 -6
- shotgun/agents/models.py +6 -0
- shotgun/agents/plan.py +15 -37
- shotgun/agents/research.py +10 -45
- shotgun/agents/specify.py +97 -0
- shotgun/agents/tasks.py +7 -36
- shotgun/agents/tools/artifact_management.py +450 -0
- shotgun/agents/tools/file_management.py +2 -2
- shotgun/artifacts/__init__.py +17 -0
- shotgun/artifacts/exceptions.py +89 -0
- shotgun/artifacts/manager.py +529 -0
- shotgun/artifacts/models.py +332 -0
- shotgun/artifacts/service.py +463 -0
- shotgun/artifacts/templates/__init__.py +10 -0
- shotgun/artifacts/templates/loader.py +252 -0
- shotgun/artifacts/templates/models.py +136 -0
- shotgun/artifacts/templates/plan/delivery_and_release_plan.yaml +66 -0
- shotgun/artifacts/templates/research/market_research.yaml +585 -0
- shotgun/artifacts/templates/research/sdk_comparison.yaml +257 -0
- shotgun/artifacts/templates/specify/prd.yaml +331 -0
- shotgun/artifacts/templates/specify/product_spec.yaml +301 -0
- shotgun/artifacts/utils.py +76 -0
- shotgun/cli/plan.py +1 -4
- shotgun/cli/specify.py +69 -0
- shotgun/cli/tasks.py +0 -4
- shotgun/logging_config.py +23 -7
- shotgun/main.py +7 -6
- shotgun/prompts/agents/partials/artifact_system.j2 +32 -0
- shotgun/prompts/agents/partials/common_agent_system_prompt.j2 +28 -2
- shotgun/prompts/agents/partials/content_formatting.j2 +65 -0
- shotgun/prompts/agents/partials/interactive_mode.j2 +10 -2
- shotgun/prompts/agents/plan.j2 +31 -32
- shotgun/prompts/agents/research.j2 +37 -29
- shotgun/prompts/agents/specify.j2 +31 -0
- shotgun/prompts/agents/tasks.j2 +27 -12
- shotgun/sdk/artifact_models.py +186 -0
- shotgun/sdk/artifacts.py +448 -0
- shotgun/tui/app.py +26 -7
- shotgun/tui/screens/chat.py +28 -3
- shotgun/tui/screens/directory_setup.py +113 -0
- {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/METADATA +2 -2
- {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/RECORD +48 -25
- shotgun/prompts/user/research.j2 +0 -5
- {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/WHEEL +0 -0
- {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/entry_points.txt +0 -0
- {shotgun_sh-0.1.0.dev11.dist-info → shotgun_sh-0.1.0.dev13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""File system manager for artifacts."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from shotgun.logging_config import setup_logger
|
|
10
|
+
|
|
11
|
+
from .exceptions import (
|
|
12
|
+
ArtifactFileSystemError,
|
|
13
|
+
ArtifactNotFoundError,
|
|
14
|
+
InvalidArtifactPathError,
|
|
15
|
+
SectionNotFoundError,
|
|
16
|
+
)
|
|
17
|
+
from .models import AgentMode, Artifact, ArtifactSection
|
|
18
|
+
from .utils import generate_artifact_name
|
|
19
|
+
|
|
20
|
+
logger = setup_logger(__name__)
|
|
21
|
+
|
|
22
|
+
TEMPLATE_FILENAME = ".template.yaml"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ArtifactManager:
|
|
26
|
+
"""Manages file system operations for artifacts within .shotgun directory."""
|
|
27
|
+
|
|
28
|
+
def __init__(self, base_path: Path | None = None) -> None:
|
|
29
|
+
"""Initialize artifact manager.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
base_path: Base path for artifacts. Defaults to .shotgun in current directory.
|
|
33
|
+
"""
|
|
34
|
+
if base_path is None:
|
|
35
|
+
base_path = Path.cwd() / ".shotgun"
|
|
36
|
+
elif isinstance(base_path, str):
|
|
37
|
+
base_path = Path(base_path)
|
|
38
|
+
|
|
39
|
+
self.base_path = base_path
|
|
40
|
+
logger.debug("Initialized ArtifactManager with base_path: %s", base_path)
|
|
41
|
+
|
|
42
|
+
def _get_artifact_path(self, agent_mode: AgentMode, artifact_id: str) -> Path:
|
|
43
|
+
"""Get the full path to an artifact directory."""
|
|
44
|
+
return self.base_path / agent_mode.value / artifact_id
|
|
45
|
+
|
|
46
|
+
def _validate_artifact_path(self, path: Path) -> None:
|
|
47
|
+
"""Validate that artifact path is within allowed directories."""
|
|
48
|
+
try:
|
|
49
|
+
resolved_path = path.resolve()
|
|
50
|
+
base_resolved = self.base_path.resolve()
|
|
51
|
+
resolved_path.relative_to(base_resolved)
|
|
52
|
+
except ValueError as e:
|
|
53
|
+
raise InvalidArtifactPathError(
|
|
54
|
+
str(path), "Path is outside .shotgun directory"
|
|
55
|
+
) from e
|
|
56
|
+
|
|
57
|
+
def _get_template_path(self, artifact_path: Path) -> Path:
|
|
58
|
+
"""Get path to template file for an artifact."""
|
|
59
|
+
return artifact_path / TEMPLATE_FILENAME
|
|
60
|
+
|
|
61
|
+
def _save_template(
|
|
62
|
+
self, artifact_path: Path, template_content: dict[str, Any]
|
|
63
|
+
) -> None:
|
|
64
|
+
"""Save template content to artifact directory."""
|
|
65
|
+
template_path = self._get_template_path(artifact_path)
|
|
66
|
+
try:
|
|
67
|
+
with open(template_path, "w", encoding="utf-8") as f:
|
|
68
|
+
yaml.dump(template_content, f, indent=2, default_flow_style=False)
|
|
69
|
+
logger.debug("Saved template file: %s", template_path)
|
|
70
|
+
except Exception as e:
|
|
71
|
+
raise ArtifactFileSystemError(
|
|
72
|
+
"save template", str(template_path), str(e)
|
|
73
|
+
) from e
|
|
74
|
+
|
|
75
|
+
def _load_template(self, artifact_path: Path) -> dict[str, Any] | None:
|
|
76
|
+
"""Load template content from artifact directory."""
|
|
77
|
+
template_path = self._get_template_path(artifact_path)
|
|
78
|
+
if not template_path.exists():
|
|
79
|
+
return None
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
with open(template_path, encoding="utf-8") as f:
|
|
83
|
+
data = yaml.safe_load(f)
|
|
84
|
+
return data if isinstance(data, dict) else None
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.warning("Failed to load template from %s: %s", template_path, str(e))
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
def artifact_exists(self, agent_mode: AgentMode, artifact_id: str) -> bool:
|
|
90
|
+
"""Check if an artifact exists."""
|
|
91
|
+
artifact_path = self._get_artifact_path(agent_mode, artifact_id)
|
|
92
|
+
return artifact_path.exists() and artifact_path.is_dir()
|
|
93
|
+
|
|
94
|
+
def create_artifact(self, artifact: Artifact) -> None:
|
|
95
|
+
"""Create a new artifact on the file system."""
|
|
96
|
+
artifact_path = self._get_artifact_path(
|
|
97
|
+
artifact.agent_mode, artifact.artifact_id
|
|
98
|
+
)
|
|
99
|
+
self._validate_artifact_path(artifact_path)
|
|
100
|
+
|
|
101
|
+
if artifact_path.exists():
|
|
102
|
+
raise ArtifactFileSystemError(
|
|
103
|
+
"create artifact",
|
|
104
|
+
str(artifact_path),
|
|
105
|
+
"Directory already exists",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
# Create artifact directory
|
|
110
|
+
artifact_path.mkdir(parents=True, exist_ok=False)
|
|
111
|
+
logger.debug("Created artifact directory: %s", artifact_path)
|
|
112
|
+
|
|
113
|
+
# Save sections
|
|
114
|
+
for section in artifact.sections:
|
|
115
|
+
self._write_section_file(artifact_path, section)
|
|
116
|
+
|
|
117
|
+
# Template saving is now handled by the service layer via save_template_file method
|
|
118
|
+
|
|
119
|
+
except Exception as e:
|
|
120
|
+
# Clean up on failure
|
|
121
|
+
if artifact_path.exists():
|
|
122
|
+
try:
|
|
123
|
+
self._remove_directory(artifact_path)
|
|
124
|
+
except Exception as cleanup_error:
|
|
125
|
+
logger.debug(
|
|
126
|
+
"Failed to cleanup directory %s: %s",
|
|
127
|
+
artifact_path,
|
|
128
|
+
cleanup_error,
|
|
129
|
+
)
|
|
130
|
+
raise ArtifactFileSystemError(
|
|
131
|
+
"create artifact", str(artifact_path), str(e)
|
|
132
|
+
) from e
|
|
133
|
+
|
|
134
|
+
def load_artifact(
|
|
135
|
+
self, agent_mode: AgentMode, artifact_id: str, name: str | None = None
|
|
136
|
+
) -> Artifact:
|
|
137
|
+
"""Load an artifact from the file system."""
|
|
138
|
+
artifact_path = self._get_artifact_path(agent_mode, artifact_id)
|
|
139
|
+
|
|
140
|
+
if not artifact_path.exists():
|
|
141
|
+
raise ArtifactNotFoundError(artifact_id, agent_mode.value)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
# Load sections
|
|
145
|
+
sections: list[ArtifactSection] = []
|
|
146
|
+
section_files = list(artifact_path.glob("*.md"))
|
|
147
|
+
|
|
148
|
+
for section_file in section_files:
|
|
149
|
+
section = self._load_section_from_file(section_file)
|
|
150
|
+
if section:
|
|
151
|
+
sections.append(section)
|
|
152
|
+
|
|
153
|
+
# Sort sections by number
|
|
154
|
+
sections.sort(key=lambda s: s.number)
|
|
155
|
+
|
|
156
|
+
# Generate a default name if none provided
|
|
157
|
+
if not name:
|
|
158
|
+
name = generate_artifact_name(artifact_id)
|
|
159
|
+
|
|
160
|
+
artifact = Artifact(
|
|
161
|
+
artifact_id=artifact_id,
|
|
162
|
+
agent_mode=agent_mode,
|
|
163
|
+
name=name,
|
|
164
|
+
sections=sections,
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return artifact
|
|
168
|
+
|
|
169
|
+
except ArtifactNotFoundError:
|
|
170
|
+
raise
|
|
171
|
+
except Exception as e:
|
|
172
|
+
raise ArtifactFileSystemError(
|
|
173
|
+
"load artifact", str(artifact_path), str(e)
|
|
174
|
+
) from e
|
|
175
|
+
|
|
176
|
+
def save_artifact(self, artifact: Artifact) -> None:
|
|
177
|
+
"""Save an existing artifact to the file system."""
|
|
178
|
+
artifact_path = self._get_artifact_path(
|
|
179
|
+
artifact.agent_mode, artifact.artifact_id
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
if not artifact_path.exists():
|
|
183
|
+
raise ArtifactNotFoundError(artifact.artifact_id, artifact.agent_mode.value)
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
# Get existing section files
|
|
187
|
+
existing_files = {f.name for f in artifact_path.glob("*.md")}
|
|
188
|
+
|
|
189
|
+
# Write current sections
|
|
190
|
+
current_files = set()
|
|
191
|
+
for section in artifact.sections:
|
|
192
|
+
filename = section.filename
|
|
193
|
+
self._write_section_file(artifact_path, section)
|
|
194
|
+
current_files.add(filename)
|
|
195
|
+
|
|
196
|
+
# Remove orphaned section files
|
|
197
|
+
orphaned_files = existing_files - current_files
|
|
198
|
+
for filename in orphaned_files:
|
|
199
|
+
file_path = artifact_path / filename
|
|
200
|
+
try:
|
|
201
|
+
file_path.unlink()
|
|
202
|
+
logger.debug("Removed orphaned section file: %s", file_path)
|
|
203
|
+
except Exception as e:
|
|
204
|
+
logger.warning(
|
|
205
|
+
"Failed to remove orphaned file %s: %s", file_path, e
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
# No metadata to save - all artifact state is in sections and template files
|
|
209
|
+
|
|
210
|
+
except ArtifactNotFoundError:
|
|
211
|
+
raise
|
|
212
|
+
except Exception as e:
|
|
213
|
+
raise ArtifactFileSystemError(
|
|
214
|
+
"save artifact", str(artifact_path), str(e)
|
|
215
|
+
) from e
|
|
216
|
+
|
|
217
|
+
def delete_artifact(self, agent_mode: AgentMode, artifact_id: str) -> None:
|
|
218
|
+
"""Delete an artifact from the file system."""
|
|
219
|
+
artifact_path = self._get_artifact_path(agent_mode, artifact_id)
|
|
220
|
+
|
|
221
|
+
if not artifact_path.exists():
|
|
222
|
+
raise ArtifactNotFoundError(artifact_id, agent_mode.value)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
self._remove_directory(artifact_path)
|
|
226
|
+
logger.debug("Deleted artifact directory: %s", artifact_path)
|
|
227
|
+
except Exception as e:
|
|
228
|
+
raise ArtifactFileSystemError(
|
|
229
|
+
"delete artifact", str(artifact_path), str(e)
|
|
230
|
+
) from e
|
|
231
|
+
|
|
232
|
+
def list_artifacts(
|
|
233
|
+
self, agent_mode: AgentMode | None = None
|
|
234
|
+
) -> list[dict[str, Any]]:
|
|
235
|
+
"""List all artifacts, optionally filtered by agent mode."""
|
|
236
|
+
artifacts = []
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
if agent_mode:
|
|
240
|
+
agent_dirs = [self.base_path / agent_mode.value]
|
|
241
|
+
else:
|
|
242
|
+
agent_dirs = [
|
|
243
|
+
self.base_path / mode.value
|
|
244
|
+
for mode in AgentMode
|
|
245
|
+
if (self.base_path / mode.value).exists()
|
|
246
|
+
]
|
|
247
|
+
|
|
248
|
+
for agent_dir in agent_dirs:
|
|
249
|
+
if not agent_dir.exists():
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
mode = AgentMode(agent_dir.name)
|
|
253
|
+
for artifact_dir in agent_dir.iterdir():
|
|
254
|
+
if artifact_dir.is_dir() and not artifact_dir.name.startswith("."):
|
|
255
|
+
try:
|
|
256
|
+
section_titles = self._get_section_titles(artifact_dir)
|
|
257
|
+
|
|
258
|
+
artifacts.append(
|
|
259
|
+
{
|
|
260
|
+
"artifact_id": artifact_dir.name,
|
|
261
|
+
"agent_mode": mode,
|
|
262
|
+
"section_count": len(section_titles),
|
|
263
|
+
"section_titles": section_titles,
|
|
264
|
+
"created_at": self._get_artifact_created_at(
|
|
265
|
+
artifact_dir
|
|
266
|
+
),
|
|
267
|
+
"updated_at": self._get_artifact_updated_at(
|
|
268
|
+
artifact_dir
|
|
269
|
+
),
|
|
270
|
+
"description": "", # No longer stored
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.warning(
|
|
275
|
+
"Failed to load artifact info from %s: %s",
|
|
276
|
+
artifact_dir,
|
|
277
|
+
e,
|
|
278
|
+
)
|
|
279
|
+
continue
|
|
280
|
+
|
|
281
|
+
except Exception as e:
|
|
282
|
+
raise ArtifactFileSystemError(
|
|
283
|
+
"list artifacts", str(self.base_path), str(e)
|
|
284
|
+
) from e
|
|
285
|
+
|
|
286
|
+
return artifacts
|
|
287
|
+
|
|
288
|
+
def _write_section_file(
|
|
289
|
+
self, artifact_path: Path, section: ArtifactSection
|
|
290
|
+
) -> None:
|
|
291
|
+
"""Write a section to its markdown file."""
|
|
292
|
+
section_path = artifact_path / section.filename
|
|
293
|
+
try:
|
|
294
|
+
content = f"# {section.title}\n\n{section.content}"
|
|
295
|
+
section_path.write_text(content, encoding="utf-8")
|
|
296
|
+
logger.debug("Wrote section file: %s", section_path)
|
|
297
|
+
except Exception as e:
|
|
298
|
+
raise ArtifactFileSystemError(
|
|
299
|
+
"write section", str(section_path), str(e)
|
|
300
|
+
) from e
|
|
301
|
+
|
|
302
|
+
def _load_section_from_file(self, section_file: Path) -> ArtifactSection | None:
|
|
303
|
+
"""Load a section from a markdown file."""
|
|
304
|
+
try:
|
|
305
|
+
# Parse filename for number and slug
|
|
306
|
+
filename = section_file.name
|
|
307
|
+
if not filename.endswith(".md"):
|
|
308
|
+
return None
|
|
309
|
+
|
|
310
|
+
name_part = filename[:-3] # Remove .md
|
|
311
|
+
if not name_part or "-" not in name_part:
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
number_str, slug = name_part.split("-", 1)
|
|
316
|
+
number = int(number_str)
|
|
317
|
+
except (ValueError, IndexError):
|
|
318
|
+
logger.warning("Invalid section filename format: %s", filename)
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
# Read content
|
|
322
|
+
content = section_file.read_text(encoding="utf-8")
|
|
323
|
+
|
|
324
|
+
# Extract title from first heading if present
|
|
325
|
+
lines = content.split("\n")
|
|
326
|
+
title = slug.replace("-", " ").title() # Default title
|
|
327
|
+
content_lines = lines
|
|
328
|
+
|
|
329
|
+
if lines and lines[0].startswith("# "):
|
|
330
|
+
title = lines[0][2:].strip()
|
|
331
|
+
content_lines = (
|
|
332
|
+
lines[2:] if len(lines) > 1 and not lines[1].strip() else lines[1:]
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
content_text = "\n".join(content_lines).strip()
|
|
336
|
+
|
|
337
|
+
return ArtifactSection(
|
|
338
|
+
number=number,
|
|
339
|
+
slug=slug,
|
|
340
|
+
title=title,
|
|
341
|
+
content=content_text,
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
logger.warning("Failed to load section from %s: %s", section_file, e)
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
def _get_section_titles(self, artifact_path: Path) -> list[str]:
|
|
349
|
+
"""Get list of section titles from artifact directory."""
|
|
350
|
+
titles = []
|
|
351
|
+
section_files = sorted(artifact_path.glob("*.md"))
|
|
352
|
+
|
|
353
|
+
for section_file in section_files:
|
|
354
|
+
section = self._load_section_from_file(section_file)
|
|
355
|
+
if section:
|
|
356
|
+
titles.append(section.title)
|
|
357
|
+
|
|
358
|
+
return titles
|
|
359
|
+
|
|
360
|
+
def _ensure_artifact_exists(self, agent_mode: AgentMode, artifact_id: str) -> Path:
|
|
361
|
+
"""Ensure artifact exists and return its path.
|
|
362
|
+
|
|
363
|
+
Args:
|
|
364
|
+
agent_mode: Agent mode
|
|
365
|
+
artifact_id: Artifact ID
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Path to the artifact directory
|
|
369
|
+
|
|
370
|
+
Raises:
|
|
371
|
+
ArtifactNotFoundError: If artifact doesn't exist
|
|
372
|
+
"""
|
|
373
|
+
artifact_path = self._get_artifact_path(agent_mode, artifact_id)
|
|
374
|
+
if not artifact_path.exists():
|
|
375
|
+
raise ArtifactNotFoundError(artifact_id, agent_mode.value)
|
|
376
|
+
return artifact_path
|
|
377
|
+
|
|
378
|
+
def _find_section_file_by_number(
|
|
379
|
+
self, artifact_path: Path, section_number: int
|
|
380
|
+
) -> Path:
|
|
381
|
+
"""Find section file by number.
|
|
382
|
+
|
|
383
|
+
Args:
|
|
384
|
+
artifact_path: Path to artifact directory
|
|
385
|
+
section_number: Section number to find
|
|
386
|
+
|
|
387
|
+
Returns:
|
|
388
|
+
Path to the section file
|
|
389
|
+
|
|
390
|
+
Raises:
|
|
391
|
+
SectionNotFoundError: If section file doesn't exist
|
|
392
|
+
"""
|
|
393
|
+
for file in artifact_path.glob("*.md"):
|
|
394
|
+
filename = file.name
|
|
395
|
+
if filename.startswith(f"{section_number:03d}-"):
|
|
396
|
+
return file
|
|
397
|
+
|
|
398
|
+
raise SectionNotFoundError(section_number, artifact_path.name)
|
|
399
|
+
|
|
400
|
+
def _remove_directory(self, path: Path) -> None:
|
|
401
|
+
"""Recursively remove a directory."""
|
|
402
|
+
if not path.exists():
|
|
403
|
+
return
|
|
404
|
+
|
|
405
|
+
for item in path.iterdir():
|
|
406
|
+
if item.is_dir():
|
|
407
|
+
self._remove_directory(item)
|
|
408
|
+
else:
|
|
409
|
+
item.unlink()
|
|
410
|
+
|
|
411
|
+
path.rmdir()
|
|
412
|
+
|
|
413
|
+
def _get_artifact_created_at(self, artifact_path: Path) -> datetime:
|
|
414
|
+
"""Get artifact creation time from filesystem."""
|
|
415
|
+
if artifact_path.exists():
|
|
416
|
+
return datetime.fromtimestamp(artifact_path.stat().st_ctime)
|
|
417
|
+
return datetime.now()
|
|
418
|
+
|
|
419
|
+
def _get_artifact_updated_at(self, artifact_path: Path) -> datetime:
|
|
420
|
+
"""Get artifact last updated time from filesystem."""
|
|
421
|
+
if not artifact_path.exists():
|
|
422
|
+
return datetime.now()
|
|
423
|
+
|
|
424
|
+
# Find the most recently modified file in the artifact directory
|
|
425
|
+
most_recent = artifact_path.stat().st_mtime
|
|
426
|
+
for file_path in artifact_path.rglob("*"):
|
|
427
|
+
if file_path.is_file():
|
|
428
|
+
file_mtime = file_path.stat().st_mtime
|
|
429
|
+
most_recent = max(most_recent, file_mtime)
|
|
430
|
+
|
|
431
|
+
return datetime.fromtimestamp(most_recent)
|
|
432
|
+
|
|
433
|
+
def save_template_file(
|
|
434
|
+
self, agent_mode: AgentMode, artifact_id: str, template_content: dict[str, Any]
|
|
435
|
+
) -> None:
|
|
436
|
+
"""Save template content to an artifact's .template.yaml file.
|
|
437
|
+
|
|
438
|
+
Args:
|
|
439
|
+
agent_mode: Agent mode
|
|
440
|
+
artifact_id: Artifact ID
|
|
441
|
+
template_content: Template content as dictionary
|
|
442
|
+
|
|
443
|
+
Raises:
|
|
444
|
+
ArtifactNotFoundError: If artifact doesn't exist
|
|
445
|
+
ArtifactFileSystemError: If template file cannot be saved
|
|
446
|
+
"""
|
|
447
|
+
artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
|
|
448
|
+
self._save_template(artifact_path, template_content)
|
|
449
|
+
|
|
450
|
+
def write_section(
|
|
451
|
+
self, agent_mode: AgentMode, artifact_id: str, section: ArtifactSection
|
|
452
|
+
) -> None:
|
|
453
|
+
"""Write a single section to its file without affecting other sections.
|
|
454
|
+
|
|
455
|
+
Args:
|
|
456
|
+
agent_mode: Agent mode
|
|
457
|
+
artifact_id: Artifact ID
|
|
458
|
+
section: Section to write
|
|
459
|
+
|
|
460
|
+
Raises:
|
|
461
|
+
ArtifactNotFoundError: If artifact doesn't exist
|
|
462
|
+
ArtifactFileSystemError: If section file cannot be written
|
|
463
|
+
"""
|
|
464
|
+
artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
|
|
465
|
+
self._write_section_file(artifact_path, section)
|
|
466
|
+
|
|
467
|
+
def update_section_file(
|
|
468
|
+
self, agent_mode: AgentMode, artifact_id: str, section: ArtifactSection
|
|
469
|
+
) -> None:
|
|
470
|
+
"""Update a single section file without affecting other sections.
|
|
471
|
+
|
|
472
|
+
Args:
|
|
473
|
+
agent_mode: Agent mode
|
|
474
|
+
artifact_id: Artifact ID
|
|
475
|
+
section: Updated section data
|
|
476
|
+
|
|
477
|
+
Raises:
|
|
478
|
+
ArtifactNotFoundError: If artifact doesn't exist
|
|
479
|
+
SectionNotFoundError: If section file doesn't exist
|
|
480
|
+
ArtifactFileSystemError: If section file cannot be updated
|
|
481
|
+
"""
|
|
482
|
+
artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
|
|
483
|
+
|
|
484
|
+
# Verify section file exists
|
|
485
|
+
section_path = artifact_path / section.filename
|
|
486
|
+
if not section_path.exists():
|
|
487
|
+
raise SectionNotFoundError(section.number, artifact_id)
|
|
488
|
+
|
|
489
|
+
self._write_section_file(artifact_path, section)
|
|
490
|
+
|
|
491
|
+
def delete_section_file(
|
|
492
|
+
self, agent_mode: AgentMode, artifact_id: str, section_number: int
|
|
493
|
+
) -> None:
|
|
494
|
+
"""Delete a single section file without affecting other sections.
|
|
495
|
+
|
|
496
|
+
Args:
|
|
497
|
+
agent_mode: Agent mode
|
|
498
|
+
artifact_id: Artifact ID
|
|
499
|
+
section_number: Section number to delete
|
|
500
|
+
|
|
501
|
+
Raises:
|
|
502
|
+
ArtifactNotFoundError: If artifact doesn't exist
|
|
503
|
+
SectionNotFoundError: If section file doesn't exist
|
|
504
|
+
ArtifactFileSystemError: If section file cannot be deleted
|
|
505
|
+
"""
|
|
506
|
+
artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
|
|
507
|
+
section_file = self._find_section_file_by_number(artifact_path, section_number)
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
section_file.unlink()
|
|
511
|
+
logger.debug("Deleted section file: %s", section_file)
|
|
512
|
+
except Exception as e:
|
|
513
|
+
raise ArtifactFileSystemError(
|
|
514
|
+
"delete section", str(section_file), str(e)
|
|
515
|
+
) from e
|
|
516
|
+
|
|
517
|
+
def read_section(
|
|
518
|
+
self, agent_mode: AgentMode, artifact_id: str, section_number: int
|
|
519
|
+
) -> str:
|
|
520
|
+
"""Read content of a specific section."""
|
|
521
|
+
artifact_path = self._ensure_artifact_exists(agent_mode, artifact_id)
|
|
522
|
+
section_file = self._find_section_file_by_number(artifact_path, section_number)
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
return section_file.read_text(encoding="utf-8")
|
|
526
|
+
except Exception as e:
|
|
527
|
+
raise ArtifactFileSystemError(
|
|
528
|
+
"read section", str(section_file), str(e)
|
|
529
|
+
) from e
|