co-agent-coder 0.1.0__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.
- co_agent_coder-0.1.0.dist-info/METADATA +396 -0
- co_agent_coder-0.1.0.dist-info/RECORD +51 -0
- co_agent_coder-0.1.0.dist-info/WHEEL +5 -0
- co_agent_coder-0.1.0.dist-info/entry_points.txt +5 -0
- co_agent_coder-0.1.0.dist-info/licenses/LICENSE +201 -0
- co_agent_coder-0.1.0.dist-info/top_level.txt +1 -0
- coagent/__init__.py +84 -0
- coagent/__main__.py +6 -0
- coagent/agent/__init__.py +27 -0
- coagent/agent/budget.py +220 -0
- coagent/agent/loop.py +1102 -0
- coagent/agent/prompts.py +228 -0
- coagent/agent/state.py +181 -0
- coagent/cli.py +351 -0
- coagent/config.py +307 -0
- coagent/context/__init__.py +26 -0
- coagent/context/artifacts.py +431 -0
- coagent/context/repo_map.py +590 -0
- coagent/context/retrieval.py +375 -0
- coagent/context/tokens.py +93 -0
- coagent/evals/__init__.py +0 -0
- coagent/gitx/__init__.py +52 -0
- coagent/gitx/commit.py +91 -0
- coagent/gitx/durable.py +147 -0
- coagent/gitx/git.py +351 -0
- coagent/gitx/lock.py +157 -0
- coagent/gitx/log.py +85 -0
- coagent/gitx/resume.py +199 -0
- coagent/gitx/session.py +233 -0
- coagent/llm/__init__.py +83 -0
- coagent/llm/base.py +235 -0
- coagent/llm/grammar.py +306 -0
- coagent/llm/registry.py +147 -0
- coagent/llm/transformers_backend.py +326 -0
- coagent/py.typed +0 -0
- coagent/safety/__init__.py +24 -0
- coagent/safety/denylist.py +146 -0
- coagent/safety/policy.py +219 -0
- coagent/safety/redact.py +146 -0
- coagent/templates/config.toml +104 -0
- coagent/tools/__init__.py +155 -0
- coagent/tools/artifacts.py +307 -0
- coagent/tools/base.py +226 -0
- coagent/tools/fs.py +507 -0
- coagent/tools/notebooks.py +875 -0
- coagent/tools/pyt.py +240 -0
- coagent/tools/shell.py +558 -0
- coagent/ui/__init__.py +50 -0
- coagent/ui/gradio_app.py +353 -0
- coagent/ui/notebook.py +342 -0
- coagent/ui/render.py +422 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
"""The artifact store: size policy, hashing, and the manifest (DESIGN §3.5).
|
|
2
|
+
|
|
3
|
+
What an artifact is
|
|
4
|
+
-------------------
|
|
5
|
+
Anything a task *produced* rather than wrote by hand: a plot from a cell, a CSV
|
|
6
|
+
from a scrape, a notebook's rendered output. The design gives them one manifest
|
|
7
|
+
and two storage classes:
|
|
8
|
+
|
|
9
|
+
=========================== ========================================= =========
|
|
10
|
+
Size Where In repo
|
|
11
|
+
=========================== ========================================= =========
|
|
12
|
+
< ``commit_under_mb`` ``artifacts/<run-id>/<name>`` committed
|
|
13
|
+
``commit_under_mb`` – 2 GB Drive ``coagent/artifacts/<sha256>`` pointer
|
|
14
|
+
> 2 GB / datasets Drive or original URL pointer
|
|
15
|
+
=========================== ========================================= =========
|
|
16
|
+
|
|
17
|
+
The rule the design states and this module enforces is: **never commit more than
|
|
18
|
+
the cap.** A 20 MB PNG in a git repo is the kind of mistake that cannot be undone
|
|
19
|
+
without a rewrite; AT-7 exists to prove it does not happen.
|
|
20
|
+
|
|
21
|
+
Why the manifest is the source of truth
|
|
22
|
+
---------------------------------------
|
|
23
|
+
The committed bytes and the Drive blob are the same artifact in two places, and
|
|
24
|
+
a resumed session on a fresh VM has to be able to tell which is which. So the
|
|
25
|
+
manifest records ``{path, sha256, bytes, store, created_by_step}`` for every
|
|
26
|
+
entry: the hash is what survives a move to Drive, so a pointer with no bytes in
|
|
27
|
+
the repo is still verifiable.
|
|
28
|
+
|
|
29
|
+
Why Drive is optional
|
|
30
|
+
---------------------
|
|
31
|
+
FR-BOOT-4: mounting Drive is an explicit user action, and declining must degrade
|
|
32
|
+
gracefully. If Drive is not mounted, an over-cap artifact is *not* silently
|
|
33
|
+
dropped or forced into git — it stays where it is and the manifest records a
|
|
34
|
+
pointer with nowhere to point yet, so the user can collect it. That is a
|
|
35
|
+
degraded capability, and the report says so.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import hashlib
|
|
41
|
+
import json
|
|
42
|
+
import re
|
|
43
|
+
from dataclasses import asdict, dataclass, field
|
|
44
|
+
from datetime import datetime, timezone
|
|
45
|
+
from pathlib import Path
|
|
46
|
+
from typing import Any
|
|
47
|
+
|
|
48
|
+
__all__ = [
|
|
49
|
+
"ARTIFACT_ROOT",
|
|
50
|
+
"ArtifactError",
|
|
51
|
+
"ArtifactRecord",
|
|
52
|
+
"ArtifactStore",
|
|
53
|
+
"Manifest",
|
|
54
|
+
"MANIFEST_NAME",
|
|
55
|
+
"MAX_DRIVE_BYTES",
|
|
56
|
+
]
|
|
57
|
+
|
|
58
|
+
#: `artifacts/<run-id>/` at the repo root, per §3.5.
|
|
59
|
+
ARTIFACT_ROOT = "artifacts"
|
|
60
|
+
MANIFEST_NAME = "manifest.json"
|
|
61
|
+
|
|
62
|
+
#: 2 GB: past this the design says "Drive or original URL", never a copy we own.
|
|
63
|
+
MAX_DRIVE_BYTES = 2 * 1024 * 1024 * 1024
|
|
64
|
+
|
|
65
|
+
#: A filesystem- and git-safe name. Artifacts come from cell outputs and model
|
|
66
|
+
#: arguments, so they are untrusted: no `..`, no separators, no absolute paths.
|
|
67
|
+
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
|
68
|
+
_IMAGE_EXT = {"image/png": ".png", "image/jpeg": ".jpg", "image/svg+xml": ".svg"}
|
|
69
|
+
_TEXT_EXT = {"text/html": ".html", "text/plain": ".txt", "text/csv": ".csv", "application/json": ".json"}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class ArtifactError(RuntimeError):
|
|
73
|
+
"""An artifact could not be stored. The message is the user's next step."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class ArtifactRecord:
|
|
78
|
+
"""One entry in the manifest (§3.5 shape, plus what the UI needs)."""
|
|
79
|
+
|
|
80
|
+
path: str
|
|
81
|
+
sha256: str
|
|
82
|
+
bytes: int
|
|
83
|
+
store: str # "repo" | "drive" | "external"
|
|
84
|
+
created_by_step: int = 0
|
|
85
|
+
#: Where the bytes actually are. For repo artifacts this is the repo-relative
|
|
86
|
+
#: path; for Drive it is the absolute path or the sha-keyed pointer.
|
|
87
|
+
location: str = ""
|
|
88
|
+
mime: str = ""
|
|
89
|
+
name: str = ""
|
|
90
|
+
#: Set when a large artifact could not reach Drive (no mount). The bytes stay
|
|
91
|
+
#: in place and this explains where.
|
|
92
|
+
note: str = ""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def in_repo(self) -> bool:
|
|
96
|
+
return self.store == "repo"
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def human_size(self) -> str:
|
|
100
|
+
size = float(self.bytes)
|
|
101
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
102
|
+
if size < 1024 or unit == "GB":
|
|
103
|
+
return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
|
|
104
|
+
size /= 1024.0
|
|
105
|
+
return f"{size:.1f} GB"
|
|
106
|
+
|
|
107
|
+
def render(self) -> str:
|
|
108
|
+
where = "committed" if self.in_repo else f"not committed ({self.store})"
|
|
109
|
+
line = f"{self.path} {self.human_size} {self.sha256[:12]} {where}"
|
|
110
|
+
if self.note:
|
|
111
|
+
line += f"\n {self.note}"
|
|
112
|
+
return line
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class Manifest:
|
|
117
|
+
"""`artifacts/<run-id>/manifest.json` — every artifact, sorted for stable diffs."""
|
|
118
|
+
|
|
119
|
+
run_id: str
|
|
120
|
+
created_at: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat(timespec="seconds"))
|
|
121
|
+
artifacts: list[ArtifactRecord] = field(default_factory=list)
|
|
122
|
+
|
|
123
|
+
def add(self, record: ArtifactRecord) -> ArtifactRecord:
|
|
124
|
+
"""Insert, replacing any earlier record for the same path.
|
|
125
|
+
|
|
126
|
+
Re-running a cell overwrites the same plot file; keeping both records
|
|
127
|
+
would make the manifest describe a file that no longer exists.
|
|
128
|
+
"""
|
|
129
|
+
self.artifacts = [a for a in self.artifacts if a.path != record.path]
|
|
130
|
+
self.artifacts.append(record)
|
|
131
|
+
self.artifacts.sort(key=lambda a: a.path)
|
|
132
|
+
return record
|
|
133
|
+
|
|
134
|
+
def to_dict(self) -> dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"run_id": self.run_id,
|
|
137
|
+
"created_at": self.created_at,
|
|
138
|
+
"artifacts": [asdict(a) for a in self.artifacts],
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
@classmethod
|
|
142
|
+
def from_dict(cls, data: dict[str, Any]) -> Manifest:
|
|
143
|
+
records = [ArtifactRecord(**item) for item in data.get("artifacts", [])]
|
|
144
|
+
return cls(
|
|
145
|
+
run_id=str(data.get("run_id", "")),
|
|
146
|
+
created_at=str(data.get("created_at", "")),
|
|
147
|
+
artifacts=records,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
def render(self) -> str:
|
|
151
|
+
if not self.artifacts:
|
|
152
|
+
return f"{self.run_id}: no artifacts"
|
|
153
|
+
lines = [f"{self.run_id}: {len(self.artifacts)} artifact(s)"]
|
|
154
|
+
lines.extend(f" {a.render()}" for a in self.artifacts)
|
|
155
|
+
return "\n".join(lines)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class ArtifactStore:
|
|
159
|
+
"""Applies the size policy and maintains the manifest.
|
|
160
|
+
|
|
161
|
+
The store owns no UI and no model: it is the one place that decides *where* a
|
|
162
|
+
produced file lives. Tools hand it bytes, the loop asks it for the committed
|
|
163
|
+
paths that go into the `Coagent-Artifacts` trailer.
|
|
164
|
+
"""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
repo_root: Path,
|
|
169
|
+
run_id: str,
|
|
170
|
+
*,
|
|
171
|
+
commit_under_mb: int = 5,
|
|
172
|
+
drive_dir: str = "coagent/artifacts",
|
|
173
|
+
drive_root: Path | None = None,
|
|
174
|
+
step: int = 0,
|
|
175
|
+
) -> None:
|
|
176
|
+
self.repo_root = Path(repo_root).resolve()
|
|
177
|
+
self.run_id = _safe_segment(run_id) or "run"
|
|
178
|
+
self.commit_limit = max(0, int(commit_under_mb)) * 1024 * 1024
|
|
179
|
+
self.drive_dir = drive_dir
|
|
180
|
+
self.drive_root = Path(drive_root).resolve() if drive_root else _detect_drive()
|
|
181
|
+
self.step = step
|
|
182
|
+
self.manifest = Manifest(run_id=self.run_id)
|
|
183
|
+
|
|
184
|
+
# ----------------------------------------------------------------- paths
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def dir(self) -> Path:
|
|
188
|
+
return self.repo_root / ARTIFACT_ROOT / self.run_id
|
|
189
|
+
|
|
190
|
+
@property
|
|
191
|
+
def manifest_path(self) -> Path:
|
|
192
|
+
return self.dir / MANIFEST_NAME
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def run_dir_rel(self) -> str:
|
|
196
|
+
return f"{ARTIFACT_ROOT}/{self.run_id}"
|
|
197
|
+
|
|
198
|
+
def rel(self, path: Path) -> str:
|
|
199
|
+
try:
|
|
200
|
+
return path.resolve().relative_to(self.repo_root).as_posix()
|
|
201
|
+
except ValueError:
|
|
202
|
+
return path.as_posix()
|
|
203
|
+
|
|
204
|
+
def _drive_target(self, sha: str, name: str) -> Path | None:
|
|
205
|
+
if self.drive_root is None:
|
|
206
|
+
return None
|
|
207
|
+
return self.drive_root / self.drive_dir / sha[:2] / f"{sha}{Path(name).suffix}"
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------ save
|
|
210
|
+
|
|
211
|
+
def save_bytes(
|
|
212
|
+
self,
|
|
213
|
+
data: bytes,
|
|
214
|
+
name: str,
|
|
215
|
+
*,
|
|
216
|
+
mime: str = "",
|
|
217
|
+
step: int | None = None,
|
|
218
|
+
subdir: str = "",
|
|
219
|
+
) -> ArtifactRecord:
|
|
220
|
+
"""Store `data` under the §3.5 policy and record it in the manifest."""
|
|
221
|
+
sha = hashlib.sha256(data).hexdigest()
|
|
222
|
+
safe = _safe_name(name, mime)
|
|
223
|
+
use_step = self.step if step is None else step
|
|
224
|
+
size = len(data)
|
|
225
|
+
|
|
226
|
+
if size > MAX_DRIVE_BYTES:
|
|
227
|
+
# Never a copy we own at this size; the caller keeps the original.
|
|
228
|
+
record = ArtifactRecord(
|
|
229
|
+
path=f"{self.run_dir_rel}/{safe}",
|
|
230
|
+
sha256=sha,
|
|
231
|
+
bytes=size,
|
|
232
|
+
store="external",
|
|
233
|
+
created_by_step=use_step,
|
|
234
|
+
mime=mime,
|
|
235
|
+
name=safe,
|
|
236
|
+
note=(
|
|
237
|
+
f"{size / 1024**3:.1f} GB is over the {MAX_DRIVE_BYTES // 1024**3} GB copy "
|
|
238
|
+
"limit; keep the original file or URL and record it here by hand."
|
|
239
|
+
),
|
|
240
|
+
)
|
|
241
|
+
return self.manifest.add(record)
|
|
242
|
+
|
|
243
|
+
if size <= self.commit_limit:
|
|
244
|
+
record = self._save_to_repo(data, safe, sha, use_step, mime, subdir)
|
|
245
|
+
else:
|
|
246
|
+
record = self._save_to_drive(data, safe, sha, use_step, mime, subdir)
|
|
247
|
+
|
|
248
|
+
self._write_manifest()
|
|
249
|
+
return record
|
|
250
|
+
|
|
251
|
+
def save_file(self, source: Path, *, name: str = "", mime: str = "", subdir: str = "") -> ArtifactRecord:
|
|
252
|
+
"""Store an existing file by copying its bytes through the same policy."""
|
|
253
|
+
source = Path(source)
|
|
254
|
+
try:
|
|
255
|
+
data = source.read_bytes()
|
|
256
|
+
except OSError as exc:
|
|
257
|
+
raise ArtifactError(f"cannot read {source}: {exc}") from exc
|
|
258
|
+
return self.save_bytes(data, name or source.name, mime=mime, subdir=subdir)
|
|
259
|
+
|
|
260
|
+
def save_text(self, text: str, name: str, *, mime: str = "text/plain", subdir: str = "") -> ArtifactRecord:
|
|
261
|
+
return self.save_bytes(text.encode("utf-8"), name, mime=mime, subdir=subdir)
|
|
262
|
+
|
|
263
|
+
def _save_to_repo(
|
|
264
|
+
self,
|
|
265
|
+
data: bytes,
|
|
266
|
+
safe: str,
|
|
267
|
+
sha: str,
|
|
268
|
+
step: int,
|
|
269
|
+
mime: str,
|
|
270
|
+
subdir: str,
|
|
271
|
+
) -> ArtifactRecord:
|
|
272
|
+
target = self.dir / (_safe_segment(subdir) + "/" if subdir else "") / safe
|
|
273
|
+
try:
|
|
274
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
275
|
+
target.write_bytes(data)
|
|
276
|
+
except OSError as exc:
|
|
277
|
+
raise ArtifactError(f"cannot write artifact {self.rel(target)}: {exc}") from exc
|
|
278
|
+
return self.manifest.add(
|
|
279
|
+
ArtifactRecord(
|
|
280
|
+
path=self.rel(target),
|
|
281
|
+
sha256=sha,
|
|
282
|
+
bytes=len(data),
|
|
283
|
+
store="repo",
|
|
284
|
+
created_by_step=step,
|
|
285
|
+
location=self.rel(target),
|
|
286
|
+
mime=mime,
|
|
287
|
+
name=safe,
|
|
288
|
+
)
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def _save_to_drive(
|
|
292
|
+
self,
|
|
293
|
+
data: bytes,
|
|
294
|
+
safe: str,
|
|
295
|
+
sha: str,
|
|
296
|
+
step: int,
|
|
297
|
+
mime: str,
|
|
298
|
+
subdir: str,
|
|
299
|
+
) -> ArtifactRecord:
|
|
300
|
+
"""Over the commit cap: Drive if mounted, otherwise an honest pointer.
|
|
301
|
+
|
|
302
|
+
The in-repo `path` is where the manifest says it *would* live, so a report
|
|
303
|
+
and the `Coagent-Artifacts` trailer name a stable identity either way.
|
|
304
|
+
"""
|
|
305
|
+
path = f"{self.run_dir_rel}/{_safe_segment(subdir) + '/' if subdir else ''}{safe}"
|
|
306
|
+
target = self._drive_target(sha, safe)
|
|
307
|
+
if target is None:
|
|
308
|
+
return self.manifest.add(
|
|
309
|
+
ArtifactRecord(
|
|
310
|
+
path=path,
|
|
311
|
+
sha256=sha,
|
|
312
|
+
bytes=len(data),
|
|
313
|
+
store="drive",
|
|
314
|
+
created_by_step=step,
|
|
315
|
+
location="",
|
|
316
|
+
mime=mime,
|
|
317
|
+
name=safe,
|
|
318
|
+
note=(
|
|
319
|
+
f"{len(data) / 1024**2:.1f} MB is over the "
|
|
320
|
+
f"{self.commit_limit // 1024**2} MB commit cap and Drive is not mounted, "
|
|
321
|
+
"so it was not committed. Mount Drive and save it again, or raise "
|
|
322
|
+
"[artifacts] commit_under_mb deliberately."
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
)
|
|
326
|
+
try:
|
|
327
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
328
|
+
target.write_bytes(data)
|
|
329
|
+
except OSError as exc:
|
|
330
|
+
raise ArtifactError(f"cannot write to Drive at {target}: {exc}") from exc
|
|
331
|
+
return self.manifest.add(
|
|
332
|
+
ArtifactRecord(
|
|
333
|
+
path=path,
|
|
334
|
+
sha256=sha,
|
|
335
|
+
bytes=len(data),
|
|
336
|
+
store="drive",
|
|
337
|
+
created_by_step=step,
|
|
338
|
+
location=str(target),
|
|
339
|
+
mime=mime,
|
|
340
|
+
name=safe,
|
|
341
|
+
)
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# -------------------------------------------------------------- manifest
|
|
345
|
+
|
|
346
|
+
def _write_manifest(self) -> Path:
|
|
347
|
+
try:
|
|
348
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
self.manifest_path.write_text(
|
|
350
|
+
json.dumps(self.manifest.to_dict(), indent=2, sort_keys=True) + "\n",
|
|
351
|
+
encoding="utf-8",
|
|
352
|
+
)
|
|
353
|
+
except OSError as exc:
|
|
354
|
+
raise ArtifactError(f"cannot write the artifact manifest: {exc}") from exc
|
|
355
|
+
return self.manifest_path
|
|
356
|
+
|
|
357
|
+
def flush(self) -> Path:
|
|
358
|
+
"""Write the manifest even when no artifact was added this step.
|
|
359
|
+
|
|
360
|
+
The manifest is committed alongside the run, so it has to exist as soon as
|
|
361
|
+
the run directory does — even if every artifact went to Drive.
|
|
362
|
+
|
|
363
|
+
An **empty** manifest is deliberately not written. Nothing references it,
|
|
364
|
+
nothing commits it, and an untracked file left in the tree makes the next
|
|
365
|
+
session refuse to start with `DirtyTreeError` — a stale empty manifest is
|
|
366
|
+
worse than none, because it breaks the resume path (FR-GIT-5).
|
|
367
|
+
"""
|
|
368
|
+
if not self.manifest.artifacts and not self.manifest_path.is_file():
|
|
369
|
+
return self.manifest_path
|
|
370
|
+
return self._write_manifest()
|
|
371
|
+
|
|
372
|
+
def committed_paths(self) -> list[str]:
|
|
373
|
+
"""Repo-relative paths for the `Coagent-Artifacts` trailer.
|
|
374
|
+
|
|
375
|
+
Only committed bytes and the manifest are named: a Drive pointer is not a
|
|
376
|
+
path in this commit, and claiming it is would make the trailer lie.
|
|
377
|
+
"""
|
|
378
|
+
paths = [a.path for a in self.manifest.artifacts if a.in_repo]
|
|
379
|
+
if paths:
|
|
380
|
+
paths.append(f"{self.run_dir_rel}/{MANIFEST_NAME}")
|
|
381
|
+
return sorted(set(paths))
|
|
382
|
+
|
|
383
|
+
def load(self) -> Manifest:
|
|
384
|
+
"""Re-read the manifest from disk, for a resumed session (FR-GIT-5)."""
|
|
385
|
+
if not self.manifest_path.is_file():
|
|
386
|
+
return self.manifest
|
|
387
|
+
try:
|
|
388
|
+
data = json.loads(self.manifest_path.read_text(encoding="utf-8"))
|
|
389
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
390
|
+
raise ArtifactError(f"cannot read {self.rel(self.manifest_path)}: {exc}") from exc
|
|
391
|
+
self.manifest = Manifest.from_dict(data)
|
|
392
|
+
return self.manifest
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
# --------------------------------------------------------------------- helpers
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _detect_drive() -> Path | None:
|
|
399
|
+
"""The Colab Drive mount, if it is actually there.
|
|
400
|
+
|
|
401
|
+
`drive.mount()` is the user's explicit action (FR-BOOT-4); this only *notices*
|
|
402
|
+
it. On a laptop there is no `/content/drive`, so large artifacts degrade to a
|
|
403
|
+
pointer rather than being forced into git.
|
|
404
|
+
"""
|
|
405
|
+
candidate = Path("/content/drive/MyDrive")
|
|
406
|
+
return candidate if candidate.is_dir() else None
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def _safe_segment(text: str, *, limit: int = 40) -> str:
|
|
410
|
+
"""A single path segment with no traversal and no separators."""
|
|
411
|
+
cleaned = _SAFE_NAME.sub("-", str(text)).strip("-.")
|
|
412
|
+
return cleaned[:limit].strip("-.") or ""
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _safe_name(name: str, mime: str) -> str:
|
|
416
|
+
"""A filename that cannot escape the artifact directory.
|
|
417
|
+
|
|
418
|
+
Cell outputs and model arguments are untrusted input, so `../../etc/passwd`
|
|
419
|
+
must not survive this function. An extension is inferred from the MIME type
|
|
420
|
+
when the name has none (a PNG from a cell is usually called `output`).
|
|
421
|
+
"""
|
|
422
|
+
base = _safe_segment(Path(str(name)).name, limit=80)
|
|
423
|
+
if not base:
|
|
424
|
+
base = "artifact"
|
|
425
|
+
if not Path(base).suffix:
|
|
426
|
+
base += _IMAGE_EXT.get(mime) or _TEXT_EXT.get(mime) or ".bin"
|
|
427
|
+
return base
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _utcnow() -> str:
|
|
431
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|