main
rs 359 lines 11.7 KB
Raw
1 #![allow(dead_code)]
2 //! Project workspace management for onde-cli fine-tuning.
3 //!
4 //! Each target HuggingFace repo (e.g. `ondeinference/joko`) gets its own
5 //! workspace under `~/.onde/projects/{org}/{name}/`. The base model lives
6 //! in the shared HuggingFace cache (read-only); all generated artifacts
7 //! (LoRA adapters, merged models, GGUF exports) go into the project dir.
8
9 use std::path::{Path, PathBuf};
10
11 use anyhow::{Context, Result};
12 use serde::{Deserialize, Serialize};
13
14 // ---------------------------------------------------------------------------
15 // Public types
16 // ---------------------------------------------------------------------------
17
18 /// Metadata stored in `project.json` at the project root.
19 #[derive(Debug, Clone, Serialize, Deserialize)]
20 pub struct ProjectMeta {
21 /// Target HuggingFace repo ID, e.g. `ondeinference/joko`.
22 pub repo_id: String,
23 /// Base model HF repo used for fine-tuning, e.g. `Qwen/Qwen3-0.6B`.
24 pub base_model_id: String,
25 /// Unix timestamp when the project was created.
26 pub created_at: u64,
27 }
28
29 /// A resolved project workspace with all paths computed.
30 #[derive(Debug, Clone)]
31 pub struct OndeProject {
32 /// Target HF repo, e.g. `ondeinference/joko`.
33 pub repo_id: String,
34 /// Base model HF repo, e.g. `Qwen/Qwen3-0.6B`.
35 pub base_model_id: String,
36 /// Resolved path to the base model in the local HF cache.
37 pub base_model_dir: String,
38 /// Root of this project workspace, e.g. `~/.onde/projects/ondeinference/joko`.
39 pub project_dir: PathBuf,
40 /// Path to the dataset file: `{project_dir}/dataset/train.jsonl`.
41 pub dataset_path: PathBuf,
42 /// Path to the runs directory: `{project_dir}/runs/`.
43 pub runs_dir: PathBuf,
44 }
45
46 // ---------------------------------------------------------------------------
47 // Public API
48 // ---------------------------------------------------------------------------
49
50 /// Root directory for all onde projects.
51 pub fn projects_root() -> PathBuf {
52 dirs::home_dir()
53 .unwrap_or_else(|| PathBuf::from("."))
54 .join(".onde")
55 .join("projects")
56 }
57
58 /// Create a new project workspace on disk and return its metadata.
59 ///
60 /// - Creates `~/.onde/projects/{repo_id}/` with `dataset/` and `runs/` subdirs.
61 /// - Writes `project.json` with the repo ID and base model.
62 /// - Does NOT overwrite an existing `project.json`.
63 pub fn create_project(repo_id: &str, base_model_id: &str) -> Result<OndeProject> {
64 let project_dir = projects_root().join(repo_id);
65 let dataset_dir = project_dir.join("dataset");
66 let runs_dir = project_dir.join("runs");
67
68 std::fs::create_dir_all(&dataset_dir)
69 .with_context(|| format!("creating {}", dataset_dir.display()))?;
70 std::fs::create_dir_all(&runs_dir)
71 .with_context(|| format!("creating {}", runs_dir.display()))?;
72
73 let meta_path = project_dir.join("project.json");
74 let meta = if meta_path.exists() {
75 // Load existing — don't overwrite (user may have changed base_model_id)
76 let raw = std::fs::read_to_string(&meta_path)
77 .with_context(|| format!("reading {}", meta_path.display()))?;
78 serde_json::from_str::<ProjectMeta>(&raw)
79 .with_context(|| format!("parsing {}", meta_path.display()))?
80 } else {
81 let now = std::time::SystemTime::now()
82 .duration_since(std::time::UNIX_EPOCH)
83 .unwrap_or_default()
84 .as_secs();
85 let meta = ProjectMeta {
86 repo_id: repo_id.to_string(),
87 base_model_id: base_model_id.to_string(),
88 created_at: now,
89 };
90 let json = serde_json::to_string_pretty(&meta).context("serializing project.json")?;
91 std::fs::write(&meta_path, json)
92 .with_context(|| format!("writing {}", meta_path.display()))?;
93 meta
94 };
95
96 Ok(OndeProject {
97 repo_id: meta.repo_id,
98 base_model_id: meta.base_model_id.clone(),
99 base_model_dir: String::new(), // resolved later by the caller
100 project_dir,
101 dataset_path: dataset_dir.join("train.jsonl"),
102 runs_dir,
103 })
104 }
105
106 /// Load an existing project from disk.
107 pub fn load_project(repo_id: &str) -> Result<OndeProject> {
108 let project_dir = projects_root().join(repo_id);
109 let meta_path = project_dir.join("project.json");
110
111 if !meta_path.exists() {
112 anyhow::bail!("no project found at {}", project_dir.display());
113 }
114
115 let raw = std::fs::read_to_string(&meta_path)
116 .with_context(|| format!("reading {}", meta_path.display()))?;
117 let meta: ProjectMeta =
118 serde_json::from_str(&raw).with_context(|| format!("parsing {}", meta_path.display()))?;
119
120 let dataset_path = project_dir.join("dataset").join("train.jsonl");
121 let runs_dir = project_dir.join("runs");
122
123 Ok(OndeProject {
124 repo_id: meta.repo_id,
125 base_model_id: meta.base_model_id,
126 base_model_dir: String::new(),
127 project_dir,
128 dataset_path,
129 runs_dir,
130 })
131 }
132
133 /// List all existing projects under `~/.onde/projects/`.
134 ///
135 /// Returns `(repo_id, ProjectMeta)` pairs sorted alphabetically.
136 pub fn list_projects() -> Vec<(String, ProjectMeta)> {
137 let root = projects_root();
138 let mut projects = Vec::new();
139
140 let Ok(orgs) = std::fs::read_dir(&root) else {
141 return projects;
142 };
143
144 for org_entry in orgs.flatten() {
145 let org_path = org_entry.path();
146 if !org_path.is_dir() {
147 continue;
148 }
149 let org_name = org_entry.file_name().to_string_lossy().to_string();
150
151 let Ok(repos) = std::fs::read_dir(&org_path) else {
152 continue;
153 };
154
155 for repo_entry in repos.flatten() {
156 let repo_path = repo_entry.path();
157 let meta_path = repo_path.join("project.json");
158 if !meta_path.exists() {
159 continue;
160 }
161
162 let repo_name = repo_entry.file_name().to_string_lossy().to_string();
163 let repo_id = format!("{org_name}/{repo_name}");
164
165 if let Ok(raw) = std::fs::read_to_string(&meta_path)
166 && let Ok(meta) = serde_json::from_str::<ProjectMeta>(&raw)
167 {
168 projects.push((repo_id, meta));
169 }
170 }
171 }
172
173 projects.sort_by(|a, b| a.0.cmp(&b.0));
174 projects
175 }
176
177 /// Generate a new timestamped run directory under the project.
178 ///
179 /// Returns the path to the run dir (e.g. `{project}/runs/1750012345/`).
180 /// The directory is created on disk.
181 pub fn new_run_dir(project: &OndeProject) -> Result<PathBuf> {
182 let ts = std::time::SystemTime::now()
183 .duration_since(std::time::UNIX_EPOCH)
184 .unwrap_or_default()
185 .as_secs();
186 let run_dir = project.runs_dir.join(format!("{ts}"));
187 std::fs::create_dir_all(&run_dir)
188 .with_context(|| format!("creating run dir {}", run_dir.display()))?;
189 Ok(run_dir)
190 }
191
192 /// Scan a project's runs directory for all artifacts (LoRA adapters and GGUFs).
193 ///
194 /// Each run is a timestamped subdirectory under `{project}/runs/`.
195 /// Returns entries sorted by modification time (newest first).
196 pub fn scan_project_artifacts(project: &OndeProject) -> Vec<ProjectArtifact> {
197 let mut artifacts = Vec::new();
198
199 let Ok(runs) = std::fs::read_dir(&project.runs_dir) else {
200 return artifacts;
201 };
202
203 for run_entry in runs.flatten() {
204 let run_dir = run_entry.path();
205 if !run_dir.is_dir() {
206 continue;
207 }
208 let run_name = run_entry.file_name().to_string_lossy().to_string();
209
210 // Check for LoRA adapter
211 let lora_file = run_dir.join("lora_adapter.safetensors");
212 if lora_file.is_file()
213 && let Some(a) = artifact_from_path(&lora_file, &run_name, ArtifactType::LoraAdapter)
214 {
215 artifacts.push(a);
216 }
217
218 // Check for merged model
219 let merged_dir = run_dir.join("merged");
220 if merged_dir.is_dir() {
221 let merged_safetensors = merged_dir.join("model.safetensors");
222 if merged_safetensors.is_file()
223 && let Some(a) =
224 artifact_from_path(&merged_safetensors, &run_name, ArtifactType::MergedModel)
225 {
226 artifacts.push(a);
227 }
228 }
229
230 // Check for GGUF files
231 if let Ok(files) = std::fs::read_dir(&run_dir) {
232 for file in files.flatten() {
233 let fname = file.file_name().to_string_lossy().to_string();
234 if fname.ends_with(".gguf")
235 && let Some(a) = artifact_from_path(&file.path(), &run_name, ArtifactType::Gguf)
236 {
237 artifacts.push(a);
238 }
239 }
240 }
241 }
242
243 // Newest first
244 artifacts.sort_by_key(|a| std::cmp::Reverse(a.modified_ts));
245 artifacts
246 }
247
248 // ---------------------------------------------------------------------------
249 // Artifact types
250 // ---------------------------------------------------------------------------
251
252 #[derive(Debug, Clone, PartialEq)]
253 pub enum ArtifactType {
254 LoraAdapter,
255 MergedModel,
256 Gguf,
257 }
258
259 /// An artifact found in a project's runs directory.
260 #[derive(Debug, Clone)]
261 pub struct ProjectArtifact {
262 /// Full path to the artifact file.
263 pub path: PathBuf,
264 /// Name of the run directory (timestamp).
265 pub run_name: String,
266 /// Filename.
267 pub file_name: String,
268 /// Human-readable file size.
269 pub size_display: String,
270 /// Relative time display ("just now", "3h ago").
271 pub modified_display: String,
272 /// Raw modification timestamp for sorting.
273 pub modified_ts: u64,
274 /// What kind of artifact this is.
275 pub kind: ArtifactType,
276 }
277
278 // ---------------------------------------------------------------------------
279 // Private helpers
280 // ---------------------------------------------------------------------------
281
282 fn artifact_from_path(path: &Path, run_name: &str, kind: ArtifactType) -> Option<ProjectArtifact> {
283 let meta = std::fs::metadata(path).ok()?;
284 let size = if meta.is_dir() {
285 dir_size(path)
286 } else {
287 meta.len()
288 };
289 let modified_ts = meta
290 .modified()
291 .ok()
292 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
293 .map(|d| d.as_secs())
294 .unwrap_or(0);
295
296 let now = std::time::SystemTime::now()
297 .duration_since(std::time::UNIX_EPOCH)
298 .unwrap_or_default()
299 .as_secs();
300 let age_secs = now.saturating_sub(modified_ts);
301 let modified_display = format_age(age_secs);
302
303 Some(ProjectArtifact {
304 path: path.to_path_buf(),
305 run_name: run_name.to_string(),
306 file_name: path
307 .file_name()
308 .unwrap_or_default()
309 .to_string_lossy()
310 .to_string(),
311 size_display: format_size(size),
312 modified_display,
313 modified_ts,
314 kind,
315 })
316 }
317
318 fn dir_size(path: &Path) -> u64 {
319 let mut total = 0u64;
320 if let Ok(entries) = std::fs::read_dir(path) {
321 for entry in entries.flatten() {
322 let p = entry.path();
323 if p.is_file() {
324 total += p.metadata().map(|m| m.len()).unwrap_or(0);
325 } else if p.is_dir() {
326 total += dir_size(&p);
327 }
328 }
329 }
330 total
331 }
332
333 fn format_size(bytes: u64) -> String {
334 const KB: u64 = 1_024;
335 const MB: u64 = 1_024 * KB;
336 const GB: u64 = 1_024 * MB;
337
338 if bytes >= GB {
339 format!("{:.1}GB", bytes as f64 / GB as f64)
340 } else if bytes >= MB {
341 format!("{}MB", bytes / MB)
342 } else if bytes >= KB {
343 format!("{}KB", bytes / KB)
344 } else {
345 format!("{}B", bytes)
346 }
347 }
348
349 fn format_age(secs: u64) -> String {
350 if secs < 60 {
351 "just now".to_string()
352 } else if secs < 3600 {
353 format!("{}m ago", secs / 60)
354 } else if secs < 86400 {
355 format!("{}h ago", secs / 3600)
356 } else {
357 format!("{}d ago", secs / 86400)
358 }
359 }