handoff-mcp-server 0.26.0 → 0.27.0
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.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
package/src/storage/sessions.rs
DELETED
|
@@ -1,859 +0,0 @@
|
|
|
1
|
-
use std::path::{Path, PathBuf};
|
|
2
|
-
|
|
3
|
-
use anyhow::{Context, Result};
|
|
4
|
-
use serde::{Deserialize, Serialize};
|
|
5
|
-
use serde_json::Value;
|
|
6
|
-
|
|
7
|
-
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
8
|
-
pub struct SessionData {
|
|
9
|
-
pub version: u32,
|
|
10
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
11
|
-
pub id: Option<String>,
|
|
12
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
13
|
-
pub ended_at: Option<String>,
|
|
14
|
-
pub summary: String,
|
|
15
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
16
|
-
pub branch: Option<String>,
|
|
17
|
-
#[serde(skip_serializing_if = "Option::is_none")]
|
|
18
|
-
pub commit: Option<String>,
|
|
19
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
20
|
-
pub dirty_files: Vec<String>,
|
|
21
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
22
|
-
pub decisions: Vec<Value>,
|
|
23
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
24
|
-
pub blockers: Vec<String>,
|
|
25
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
26
|
-
pub checklist: Vec<Value>,
|
|
27
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
28
|
-
pub handoff_notes: Vec<Value>,
|
|
29
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
30
|
-
pub references: Vec<Value>,
|
|
31
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
32
|
-
pub context_pointers: Vec<Value>,
|
|
33
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
34
|
-
pub environment: Option<Value>,
|
|
35
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
36
|
-
pub timeline: Option<String>,
|
|
37
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
38
|
-
pub label: Option<String>,
|
|
39
|
-
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
40
|
-
pub parent_session_id: Option<String>,
|
|
41
|
-
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
42
|
-
pub related_task_ids: Vec<String>,
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
pub fn generate_session_id() -> String {
|
|
46
|
-
let now = chrono::Utc::now();
|
|
47
|
-
format!("s-{}", now.format("%Y%m%d-%H%M%S-%6f"))
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
|
|
51
|
-
let slug = summary_to_slug(summary);
|
|
52
|
-
format!("{timestamp}-{slug}")
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
fn summary_to_slug(summary: &str) -> String {
|
|
56
|
-
let slug: String = summary
|
|
57
|
-
.chars()
|
|
58
|
-
.take(40)
|
|
59
|
-
.map(|c| {
|
|
60
|
-
if c.is_ascii_alphanumeric() {
|
|
61
|
-
c.to_ascii_lowercase()
|
|
62
|
-
} else if !c.is_ascii() {
|
|
63
|
-
c
|
|
64
|
-
} else {
|
|
65
|
-
'-'
|
|
66
|
-
}
|
|
67
|
-
})
|
|
68
|
-
.collect();
|
|
69
|
-
let slug = slug.trim_matches('-').to_string();
|
|
70
|
-
let mut result = String::new();
|
|
71
|
-
let mut prev_dash = false;
|
|
72
|
-
for c in slug.chars() {
|
|
73
|
-
if c == '-' {
|
|
74
|
-
if !prev_dash {
|
|
75
|
-
result.push(c);
|
|
76
|
-
}
|
|
77
|
-
prev_dash = true;
|
|
78
|
-
} else {
|
|
79
|
-
result.push(c);
|
|
80
|
-
prev_dash = false;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
if result.is_empty() {
|
|
84
|
-
"session".to_string()
|
|
85
|
-
} else {
|
|
86
|
-
result
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
fn extract_timestamp_from_id(id: &str) -> Option<String> {
|
|
91
|
-
// "s-20260704-003557-792065" → "20260704-003557"
|
|
92
|
-
let rest = id.strip_prefix("s-")?;
|
|
93
|
-
if rest.len() >= 15 {
|
|
94
|
-
Some(rest[..15].to_string())
|
|
95
|
-
} else {
|
|
96
|
-
None
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
fn compact_timestamp(data: &SessionData) -> String {
|
|
101
|
-
data.ended_at
|
|
102
|
-
.as_deref()
|
|
103
|
-
.map(|ts| {
|
|
104
|
-
let compact = ts
|
|
105
|
-
.replace(['-', ':'], "")
|
|
106
|
-
.replace('T', "-")
|
|
107
|
-
.replace('Z', "");
|
|
108
|
-
if compact.len() >= 15 {
|
|
109
|
-
compact[..15].to_string()
|
|
110
|
-
} else {
|
|
111
|
-
compact
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
.or_else(|| data.id.as_deref().and_then(extract_timestamp_from_id))
|
|
115
|
-
.unwrap_or_else(|| "00000000-000000".to_string())
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
fn synthesize_id_from_filename(filename: &str) -> String {
|
|
119
|
-
// Old format: YYYYMMDD-HHMMSS-slug.status.json
|
|
120
|
-
// Extract timestamp part and create s-YYYYMMDD-HHMMSS-000000
|
|
121
|
-
let base = filename
|
|
122
|
-
.rsplit_once('.')
|
|
123
|
-
.and_then(|(rest, _)| rest.rsplit_once('.'))
|
|
124
|
-
.map(|(rest, _)| rest)
|
|
125
|
-
.unwrap_or(filename);
|
|
126
|
-
|
|
127
|
-
if base.len() >= 15 {
|
|
128
|
-
let ts = &base[..15]; // YYYYMMDD-HHMMSS
|
|
129
|
-
format!("s-{ts}-000000")
|
|
130
|
-
} else {
|
|
131
|
-
format!("s-{base}-000000")
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
fn ids_match(candidate: &str, query: &str) -> bool {
|
|
136
|
-
candidate == query || candidate.starts_with(query) || query.starts_with(candidate)
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
140
|
-
let mut data = data.clone();
|
|
141
|
-
if data.id.is_none() {
|
|
142
|
-
data.id = Some(generate_session_id());
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let ts_part = compact_timestamp(&data);
|
|
146
|
-
let base = generate_session_filename(&data.summary, &ts_part);
|
|
147
|
-
let filename = format!("{base}.open.json");
|
|
148
|
-
let path = sessions_dir.join(&filename);
|
|
149
|
-
|
|
150
|
-
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
151
|
-
crate::storage::atomic_write(&path, content.as_bytes())
|
|
152
|
-
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
153
|
-
|
|
154
|
-
Ok(path)
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
pub fn read_sessions_by_status(sessions_dir: &Path, status: &str) -> Result<Vec<SessionData>> {
|
|
158
|
-
let mut sessions = Vec::new();
|
|
159
|
-
let suffix = format!(".{status}.json");
|
|
160
|
-
|
|
161
|
-
if !sessions_dir.exists() {
|
|
162
|
-
return Ok(sessions);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
let mut entries: Vec<_> = std::fs::read_dir(sessions_dir)?
|
|
166
|
-
.filter_map(|e| e.ok())
|
|
167
|
-
.collect();
|
|
168
|
-
entries.sort_by_key(|e| e.file_name());
|
|
169
|
-
|
|
170
|
-
for entry in entries {
|
|
171
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
172
|
-
if name.ends_with(&suffix) {
|
|
173
|
-
let content = std::fs::read_to_string(entry.path())
|
|
174
|
-
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
175
|
-
let mut data: SessionData = serde_json::from_str(&content)
|
|
176
|
-
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
177
|
-
if data.id.is_none() {
|
|
178
|
-
data.id = Some(synthesize_id_from_filename(&name));
|
|
179
|
-
}
|
|
180
|
-
sessions.push(data);
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
Ok(sessions)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
pub fn read_open_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
188
|
-
read_sessions_by_status(sessions_dir, "open")
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
192
|
-
read_sessions_by_status(sessions_dir, "active")
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/// Append `decision` to active session(s). When `target_session_id` is Some,
|
|
196
|
-
/// only the matching session is updated; otherwise all active sessions are updated.
|
|
197
|
-
pub fn append_decision_to_active_sessions(
|
|
198
|
-
sessions_dir: &Path,
|
|
199
|
-
decision: serde_json::Value,
|
|
200
|
-
target_session_id: Option<&str>,
|
|
201
|
-
) -> Result<usize> {
|
|
202
|
-
let suffix = ".active.json";
|
|
203
|
-
if !sessions_dir.exists() {
|
|
204
|
-
return Ok(0);
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
let mut count = 0;
|
|
208
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
209
|
-
let entry = entry?;
|
|
210
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
211
|
-
if !name.ends_with(suffix) {
|
|
212
|
-
continue;
|
|
213
|
-
}
|
|
214
|
-
let path = entry.path();
|
|
215
|
-
let content = std::fs::read_to_string(&path)
|
|
216
|
-
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
217
|
-
let mut data: SessionData = serde_json::from_str(&content)
|
|
218
|
-
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
219
|
-
|
|
220
|
-
if let Some(tid) = target_session_id {
|
|
221
|
-
let file_id = data.id.as_deref().unwrap_or("");
|
|
222
|
-
if !ids_match(file_id, tid) {
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
data.decisions.push(decision.clone());
|
|
228
|
-
let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
229
|
-
crate::storage::atomic_write(&path, updated.as_bytes())
|
|
230
|
-
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
231
|
-
count += 1;
|
|
232
|
-
}
|
|
233
|
-
Ok(count)
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
|
|
237
|
-
let mut transitioned = Vec::new();
|
|
238
|
-
let from_suffix = format!(".{from}.json");
|
|
239
|
-
let to_suffix = format!(".{to}.json");
|
|
240
|
-
|
|
241
|
-
if !sessions_dir.exists() {
|
|
242
|
-
return Ok(transitioned);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
246
|
-
let entry = entry?;
|
|
247
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
248
|
-
if name.ends_with(&from_suffix) {
|
|
249
|
-
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
250
|
-
let new_path = sessions_dir.join(&new_name);
|
|
251
|
-
std::fs::rename(entry.path(), &new_path).with_context(|| {
|
|
252
|
-
format!(
|
|
253
|
-
"Failed to transition session {from}->{to}: {}",
|
|
254
|
-
entry.path().display()
|
|
255
|
-
)
|
|
256
|
-
})?;
|
|
257
|
-
transitioned.push(new_path);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
Ok(transitioned)
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
fn transition_session_by_id(
|
|
265
|
-
sessions_dir: &Path,
|
|
266
|
-
session_id: &str,
|
|
267
|
-
from: &str,
|
|
268
|
-
to: &str,
|
|
269
|
-
) -> Result<Option<PathBuf>> {
|
|
270
|
-
let from_suffix = format!(".{from}.json");
|
|
271
|
-
let to_suffix = format!(".{to}.json");
|
|
272
|
-
|
|
273
|
-
if !sessions_dir.exists() {
|
|
274
|
-
return Ok(None);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
|
|
278
|
-
|
|
279
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
280
|
-
let entry = entry?;
|
|
281
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
282
|
-
if !name.ends_with(&from_suffix) {
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
let content = std::fs::read_to_string(entry.path())
|
|
287
|
-
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
288
|
-
let data: SessionData = serde_json::from_str(&content)
|
|
289
|
-
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
290
|
-
|
|
291
|
-
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
292
|
-
let resolved_id = if file_id.is_empty() {
|
|
293
|
-
synthesize_id_from_filename(&name)
|
|
294
|
-
} else {
|
|
295
|
-
file_id
|
|
296
|
-
};
|
|
297
|
-
|
|
298
|
-
if ids_match(&resolved_id, session_id) {
|
|
299
|
-
matches.push((entry.path(), name, resolved_id));
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
if matches.len() > 1 {
|
|
304
|
-
let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
|
|
305
|
-
anyhow::bail!(
|
|
306
|
-
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
307
|
-
session_id,
|
|
308
|
-
matches.len(),
|
|
309
|
-
candidates.join(", ")
|
|
310
|
-
);
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
if let Some((path, name, _)) = matches.into_iter().next() {
|
|
314
|
-
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
315
|
-
let new_path = sessions_dir.join(&new_name);
|
|
316
|
-
std::fs::rename(&path, &new_path).with_context(|| {
|
|
317
|
-
format!(
|
|
318
|
-
"Failed to transition session {from}->{to}: {}",
|
|
319
|
-
path.display()
|
|
320
|
-
)
|
|
321
|
-
})?;
|
|
322
|
-
return Ok(Some(new_path));
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
Ok(None)
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
pub fn activate_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
329
|
-
transition_sessions(sessions_dir, "open", "active")
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
pub fn activate_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
333
|
-
transition_session_by_id(sessions_dir, session_id, "open", "active")
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
pub fn close_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
337
|
-
transition_sessions(sessions_dir, "active", "closed")
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
pub fn close_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
341
|
-
transition_sessions(sessions_dir, "open", "closed")
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
pub fn close_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
345
|
-
// Try active first, then open, then paused
|
|
346
|
-
if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "closed")? {
|
|
347
|
-
return Ok(Some(path));
|
|
348
|
-
}
|
|
349
|
-
if let Some(path) = transition_session_by_id(sessions_dir, session_id, "open", "closed")? {
|
|
350
|
-
return Ok(Some(path));
|
|
351
|
-
}
|
|
352
|
-
transition_session_by_id(sessions_dir, session_id, "paused", "closed")
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
pub fn pause_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
356
|
-
transition_sessions(sessions_dir, "active", "paused")
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
pub fn pause_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
360
|
-
if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "paused")? {
|
|
361
|
-
return Ok(Some(path));
|
|
362
|
-
}
|
|
363
|
-
transition_session_by_id(sessions_dir, session_id, "open", "paused")
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
pub fn resume_paused_session_by_id(
|
|
367
|
-
sessions_dir: &Path,
|
|
368
|
-
session_id: &str,
|
|
369
|
-
) -> Result<Option<PathBuf>> {
|
|
370
|
-
transition_session_by_id(sessions_dir, session_id, "paused", "active")
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
pub fn read_paused_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
374
|
-
read_sessions_by_status(sessions_dir, "paused")
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
pub fn close_paused_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
378
|
-
transition_sessions(sessions_dir, "paused", "closed")
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
pub fn read_latest_closed_session(sessions_dir: &Path) -> Result<Option<SessionData>> {
|
|
382
|
-
let mut sessions = read_sessions_by_status(sessions_dir, "closed")?;
|
|
383
|
-
sessions.sort_by(|a, b| a.ended_at.cmp(&b.ended_at));
|
|
384
|
-
Ok(sessions.into_iter().last())
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
fn apply_session_updates(
|
|
388
|
-
data: &mut SessionData,
|
|
389
|
-
updates: &SessionData,
|
|
390
|
-
provided_keys: Option<&Value>,
|
|
391
|
-
) {
|
|
392
|
-
data.summary = updates.summary.clone();
|
|
393
|
-
data.ended_at = updates.ended_at.clone();
|
|
394
|
-
data.branch = updates.branch.clone();
|
|
395
|
-
data.commit = updates.commit.clone();
|
|
396
|
-
data.dirty_files = updates.dirty_files.clone();
|
|
397
|
-
|
|
398
|
-
let was_provided = |key: &str| provided_keys.and_then(|v| v.get(key)).is_some();
|
|
399
|
-
|
|
400
|
-
if was_provided("decisions") {
|
|
401
|
-
data.decisions = updates.decisions.clone();
|
|
402
|
-
}
|
|
403
|
-
if was_provided("blockers") {
|
|
404
|
-
data.blockers = updates.blockers.clone();
|
|
405
|
-
}
|
|
406
|
-
if was_provided("checklist") {
|
|
407
|
-
data.checklist = updates.checklist.clone();
|
|
408
|
-
}
|
|
409
|
-
if was_provided("handoff_notes") {
|
|
410
|
-
data.handoff_notes = updates.handoff_notes.clone();
|
|
411
|
-
}
|
|
412
|
-
if was_provided("references") {
|
|
413
|
-
data.references = updates.references.clone();
|
|
414
|
-
}
|
|
415
|
-
if was_provided("context_pointers") {
|
|
416
|
-
data.context_pointers = updates.context_pointers.clone();
|
|
417
|
-
}
|
|
418
|
-
if updates.environment.is_some() {
|
|
419
|
-
data.environment = updates.environment.clone();
|
|
420
|
-
}
|
|
421
|
-
if updates.timeline.is_some() {
|
|
422
|
-
data.timeline = updates.timeline.clone();
|
|
423
|
-
}
|
|
424
|
-
if updates.label.is_some() {
|
|
425
|
-
data.label = updates.label.clone();
|
|
426
|
-
}
|
|
427
|
-
if was_provided("related_task_ids") && !updates.related_task_ids.is_empty() {
|
|
428
|
-
data.related_task_ids = updates.related_task_ids.clone();
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
fn find_and_update_active_session(
|
|
433
|
-
sessions_dir: &Path,
|
|
434
|
-
session_id: &str,
|
|
435
|
-
updates: &SessionData,
|
|
436
|
-
transition_to: Option<&str>,
|
|
437
|
-
provided_keys: Option<&Value>,
|
|
438
|
-
) -> Result<Option<PathBuf>> {
|
|
439
|
-
let suffix = ".active.json";
|
|
440
|
-
|
|
441
|
-
if !sessions_dir.exists() {
|
|
442
|
-
return Ok(None);
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
|
|
446
|
-
|
|
447
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
448
|
-
let entry = entry?;
|
|
449
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
450
|
-
if !name.ends_with(suffix) {
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
let content = std::fs::read_to_string(entry.path())
|
|
455
|
-
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
456
|
-
let data: SessionData = serde_json::from_str(&content)
|
|
457
|
-
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
458
|
-
|
|
459
|
-
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
460
|
-
let resolved_id = if file_id.is_empty() {
|
|
461
|
-
synthesize_id_from_filename(&name)
|
|
462
|
-
} else {
|
|
463
|
-
file_id
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
if ids_match(&resolved_id, session_id) {
|
|
467
|
-
matches.push((entry.path(), name, resolved_id));
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
if matches.len() > 1 {
|
|
472
|
-
let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
|
|
473
|
-
anyhow::bail!(
|
|
474
|
-
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
475
|
-
session_id,
|
|
476
|
-
matches.len(),
|
|
477
|
-
candidates.join(", ")
|
|
478
|
-
);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
if let Some((path, _, _)) = matches.into_iter().next() {
|
|
482
|
-
let content = std::fs::read_to_string(&path)
|
|
483
|
-
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
484
|
-
let mut data: SessionData = serde_json::from_str(&content)
|
|
485
|
-
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
486
|
-
|
|
487
|
-
apply_session_updates(&mut data, updates, provided_keys);
|
|
488
|
-
|
|
489
|
-
let updated_content =
|
|
490
|
-
serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
491
|
-
crate::storage::atomic_write(&path, updated_content.as_bytes())
|
|
492
|
-
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
493
|
-
|
|
494
|
-
if let Some(target_status) = transition_to {
|
|
495
|
-
let ts_part = compact_timestamp(&data);
|
|
496
|
-
let base = generate_session_filename(&data.summary, &ts_part);
|
|
497
|
-
let new_name = format!("{base}.{target_status}.json");
|
|
498
|
-
let new_path = sessions_dir.join(&new_name);
|
|
499
|
-
std::fs::rename(&path, &new_path).with_context(|| {
|
|
500
|
-
format!(
|
|
501
|
-
"Failed to transition session active->{target_status}: {}",
|
|
502
|
-
path.display()
|
|
503
|
-
)
|
|
504
|
-
})?;
|
|
505
|
-
return Ok(Some(new_path));
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
return Ok(Some(path));
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
Ok(None)
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
pub fn update_and_close_active_session(
|
|
515
|
-
sessions_dir: &Path,
|
|
516
|
-
session_id: &str,
|
|
517
|
-
updates: &SessionData,
|
|
518
|
-
provided_keys: Option<&Value>,
|
|
519
|
-
) -> Result<Option<PathBuf>> {
|
|
520
|
-
find_and_update_active_session(
|
|
521
|
-
sessions_dir,
|
|
522
|
-
session_id,
|
|
523
|
-
updates,
|
|
524
|
-
Some("closed"),
|
|
525
|
-
provided_keys,
|
|
526
|
-
)
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
pub fn update_active_session(
|
|
530
|
-
sessions_dir: &Path,
|
|
531
|
-
session_id: &str,
|
|
532
|
-
updates: &SessionData,
|
|
533
|
-
provided_keys: Option<&Value>,
|
|
534
|
-
) -> Result<Option<PathBuf>> {
|
|
535
|
-
find_and_update_active_session(sessions_dir, session_id, updates, None, provided_keys)
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
pub fn read_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<SessionData>> {
|
|
539
|
-
if !sessions_dir.exists() {
|
|
540
|
-
return Ok(None);
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
let mut matches: Vec<(SessionData, String)> = Vec::new();
|
|
544
|
-
|
|
545
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
546
|
-
let entry = entry?;
|
|
547
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
548
|
-
if !name.ends_with(".json") {
|
|
549
|
-
continue;
|
|
550
|
-
}
|
|
551
|
-
let content = std::fs::read_to_string(entry.path())
|
|
552
|
-
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
553
|
-
let data: SessionData = serde_json::from_str(&content)
|
|
554
|
-
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
555
|
-
|
|
556
|
-
let file_id = data.id.as_deref().unwrap_or("");
|
|
557
|
-
let resolved_id = if file_id.is_empty() {
|
|
558
|
-
synthesize_id_from_filename(&name)
|
|
559
|
-
} else {
|
|
560
|
-
file_id.to_string()
|
|
561
|
-
};
|
|
562
|
-
|
|
563
|
-
if ids_match(&resolved_id, session_id) {
|
|
564
|
-
matches.push((data, resolved_id));
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
if matches.len() > 1 {
|
|
569
|
-
let candidates: Vec<&str> = matches.iter().map(|(_, id)| id.as_str()).collect();
|
|
570
|
-
anyhow::bail!(
|
|
571
|
-
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
572
|
-
session_id,
|
|
573
|
-
matches.len(),
|
|
574
|
-
candidates.join(", ")
|
|
575
|
-
);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
Ok(matches.into_iter().next().map(|(data, _)| data))
|
|
579
|
-
}
|
|
580
|
-
|
|
581
|
-
pub fn fork_session(
|
|
582
|
-
sessions_dir: &Path,
|
|
583
|
-
source: &SessionData,
|
|
584
|
-
summary: &str,
|
|
585
|
-
label: Option<&str>,
|
|
586
|
-
timeline: Option<&str>,
|
|
587
|
-
related_task_ids: Vec<String>,
|
|
588
|
-
inherit: &[&str],
|
|
589
|
-
) -> Result<SessionData> {
|
|
590
|
-
let source_id = source
|
|
591
|
-
.id
|
|
592
|
-
.as_deref()
|
|
593
|
-
.ok_or_else(|| anyhow::anyhow!("Source session has no ID"))?;
|
|
594
|
-
|
|
595
|
-
let mut forked = SessionData {
|
|
596
|
-
version: source.version,
|
|
597
|
-
id: Some(generate_session_id()),
|
|
598
|
-
ended_at: None,
|
|
599
|
-
summary: summary.to_string(),
|
|
600
|
-
branch: None,
|
|
601
|
-
commit: None,
|
|
602
|
-
dirty_files: Vec::new(),
|
|
603
|
-
decisions: Vec::new(),
|
|
604
|
-
blockers: Vec::new(),
|
|
605
|
-
checklist: Vec::new(),
|
|
606
|
-
handoff_notes: Vec::new(),
|
|
607
|
-
references: Vec::new(),
|
|
608
|
-
context_pointers: Vec::new(),
|
|
609
|
-
environment: None,
|
|
610
|
-
timeline: timeline
|
|
611
|
-
.map(String::from)
|
|
612
|
-
.or_else(|| source.timeline.clone()),
|
|
613
|
-
label: label.map(String::from),
|
|
614
|
-
parent_session_id: Some(source_id.to_string()),
|
|
615
|
-
related_task_ids,
|
|
616
|
-
};
|
|
617
|
-
|
|
618
|
-
for field in inherit {
|
|
619
|
-
match *field {
|
|
620
|
-
"decisions" => forked.decisions = source.decisions.clone(),
|
|
621
|
-
"context_pointers" => forked.context_pointers = source.context_pointers.clone(),
|
|
622
|
-
"references" => forked.references = source.references.clone(),
|
|
623
|
-
"handoff_notes" => forked.handoff_notes = source.handoff_notes.clone(),
|
|
624
|
-
"environment" => forked.environment = source.environment.clone(),
|
|
625
|
-
"blockers" => forked.blockers = source.blockers.clone(),
|
|
626
|
-
"checklist" => forked.checklist = source.checklist.clone(),
|
|
627
|
-
_ => {}
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
|
|
631
|
-
write_session_with_status(sessions_dir, &forked, "active")?;
|
|
632
|
-
Ok(forked)
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
pub fn merge_sessions(
|
|
636
|
-
sessions_dir: &Path,
|
|
637
|
-
source_ids: &[&str],
|
|
638
|
-
target_id: &str,
|
|
639
|
-
close_sources: bool,
|
|
640
|
-
) -> Result<MergeResult> {
|
|
641
|
-
let mut sources: Vec<SessionData> = Vec::new();
|
|
642
|
-
for sid in source_ids {
|
|
643
|
-
let session = read_session_by_id(sessions_dir, sid)?
|
|
644
|
-
.ok_or_else(|| anyhow::anyhow!("Source session not found: {sid}"))?;
|
|
645
|
-
sources.push(session);
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
let mut target = read_session_by_id(sessions_dir, target_id)?
|
|
649
|
-
.ok_or_else(|| anyhow::anyhow!("Target session not found: {target_id}"))?;
|
|
650
|
-
|
|
651
|
-
let target_actual_id = target.id.clone().unwrap_or_default();
|
|
652
|
-
|
|
653
|
-
let mut merged_decisions = 0usize;
|
|
654
|
-
let mut merged_notes = 0usize;
|
|
655
|
-
let mut merged_references = 0usize;
|
|
656
|
-
let mut merged_context_pointers = 0usize;
|
|
657
|
-
let mut conflicts = Vec::new();
|
|
658
|
-
let mut closed_sessions = Vec::new();
|
|
659
|
-
|
|
660
|
-
for source in &sources {
|
|
661
|
-
let source_actual_id = source.id.as_deref().unwrap_or("");
|
|
662
|
-
if source_actual_id == target_actual_id {
|
|
663
|
-
continue;
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
for d in &source.decisions {
|
|
667
|
-
let decision_text = d.get("decision").and_then(|v| v.as_str()).unwrap_or("");
|
|
668
|
-
let already_exists = target.decisions.iter().any(|td| {
|
|
669
|
-
td.get("decision").and_then(|v| v.as_str()).unwrap_or("") == decision_text
|
|
670
|
-
});
|
|
671
|
-
if already_exists {
|
|
672
|
-
conflicts.push(MergeConflict {
|
|
673
|
-
conflict_type: "decision_conflict".to_string(),
|
|
674
|
-
description: format!("Duplicate decision: {decision_text}"),
|
|
675
|
-
session_a: target_actual_id.clone(),
|
|
676
|
-
session_b: source_actual_id.to_string(),
|
|
677
|
-
});
|
|
678
|
-
} else {
|
|
679
|
-
target.decisions.push(d.clone());
|
|
680
|
-
merged_decisions += 1;
|
|
681
|
-
}
|
|
682
|
-
}
|
|
683
|
-
|
|
684
|
-
for n in &source.handoff_notes {
|
|
685
|
-
target.handoff_notes.push(n.clone());
|
|
686
|
-
merged_notes += 1;
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
for r in &source.references {
|
|
690
|
-
let label = r.get("label").and_then(|v| v.as_str()).unwrap_or("");
|
|
691
|
-
let already_exists = target
|
|
692
|
-
.references
|
|
693
|
-
.iter()
|
|
694
|
-
.any(|tr| tr.get("label").and_then(|v| v.as_str()).unwrap_or("") == label);
|
|
695
|
-
if !already_exists {
|
|
696
|
-
target.references.push(r.clone());
|
|
697
|
-
merged_references += 1;
|
|
698
|
-
}
|
|
699
|
-
}
|
|
700
|
-
|
|
701
|
-
for cp in &source.context_pointers {
|
|
702
|
-
let path = cp.get("path").and_then(|v| v.as_str()).unwrap_or("");
|
|
703
|
-
let already_exists = target
|
|
704
|
-
.context_pointers
|
|
705
|
-
.iter()
|
|
706
|
-
.any(|tcp| tcp.get("path").and_then(|v| v.as_str()).unwrap_or("") == path);
|
|
707
|
-
if !already_exists {
|
|
708
|
-
target.context_pointers.push(cp.clone());
|
|
709
|
-
merged_context_pointers += 1;
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
for task_id in &source.related_task_ids {
|
|
714
|
-
if !target.related_task_ids.contains(task_id) {
|
|
715
|
-
target.related_task_ids.push(task_id.clone());
|
|
716
|
-
}
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
if close_sources {
|
|
720
|
-
if let Some(path) = close_session_by_id(sessions_dir, source_actual_id)? {
|
|
721
|
-
let _ = path;
|
|
722
|
-
closed_sessions.push(source_actual_id.to_string());
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
update_session_in_place(sessions_dir, &target_actual_id, &target)?;
|
|
728
|
-
|
|
729
|
-
Ok(MergeResult {
|
|
730
|
-
merged_session_id: target_actual_id,
|
|
731
|
-
merged_decisions,
|
|
732
|
-
merged_notes,
|
|
733
|
-
merged_references,
|
|
734
|
-
merged_context_pointers,
|
|
735
|
-
conflicts,
|
|
736
|
-
closed_sessions,
|
|
737
|
-
})
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
fn update_session_in_place(
|
|
741
|
-
sessions_dir: &Path,
|
|
742
|
-
session_id: &str,
|
|
743
|
-
updated: &SessionData,
|
|
744
|
-
) -> Result<()> {
|
|
745
|
-
if !sessions_dir.exists() {
|
|
746
|
-
anyhow::bail!("Sessions directory not found");
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
let mut matches: Vec<(PathBuf, String)> = Vec::new();
|
|
750
|
-
|
|
751
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
752
|
-
let entry = entry?;
|
|
753
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
754
|
-
if !name.ends_with(".json") {
|
|
755
|
-
continue;
|
|
756
|
-
}
|
|
757
|
-
let content = std::fs::read_to_string(entry.path())?;
|
|
758
|
-
let data: SessionData = serde_json::from_str(&content)?;
|
|
759
|
-
let file_id = data.id.as_deref().unwrap_or("");
|
|
760
|
-
let resolved_id = if file_id.is_empty() {
|
|
761
|
-
synthesize_id_from_filename(&name)
|
|
762
|
-
} else {
|
|
763
|
-
file_id.to_string()
|
|
764
|
-
};
|
|
765
|
-
if ids_match(&resolved_id, session_id) {
|
|
766
|
-
matches.push((entry.path(), resolved_id));
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
|
|
770
|
-
if matches.len() > 1 {
|
|
771
|
-
let candidates: Vec<&str> = matches.iter().map(|(_, id)| id.as_str()).collect();
|
|
772
|
-
anyhow::bail!(
|
|
773
|
-
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
774
|
-
session_id,
|
|
775
|
-
matches.len(),
|
|
776
|
-
candidates.join(", ")
|
|
777
|
-
);
|
|
778
|
-
}
|
|
779
|
-
|
|
780
|
-
if let Some((path, _)) = matches.into_iter().next() {
|
|
781
|
-
let serialized =
|
|
782
|
-
serde_json::to_string_pretty(updated).context("Failed to serialize session")?;
|
|
783
|
-
crate::storage::atomic_write(path, serialized.as_bytes())?;
|
|
784
|
-
return Ok(());
|
|
785
|
-
}
|
|
786
|
-
|
|
787
|
-
anyhow::bail!("Session not found for in-place update: {session_id}")
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
#[derive(Debug)]
|
|
791
|
-
pub struct MergeConflict {
|
|
792
|
-
pub conflict_type: String,
|
|
793
|
-
pub description: String,
|
|
794
|
-
pub session_a: String,
|
|
795
|
-
pub session_b: String,
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
#[derive(Debug)]
|
|
799
|
-
pub struct MergeResult {
|
|
800
|
-
pub merged_session_id: String,
|
|
801
|
-
pub merged_decisions: usize,
|
|
802
|
-
pub merged_notes: usize,
|
|
803
|
-
pub merged_references: usize,
|
|
804
|
-
pub merged_context_pointers: usize,
|
|
805
|
-
pub conflicts: Vec<MergeConflict>,
|
|
806
|
-
pub closed_sessions: Vec<String>,
|
|
807
|
-
}
|
|
808
|
-
|
|
809
|
-
pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
810
|
-
if !sessions_dir.exists() {
|
|
811
|
-
return Ok(0);
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
let mut closed_files: Vec<PathBuf> = Vec::new();
|
|
815
|
-
|
|
816
|
-
for entry in std::fs::read_dir(sessions_dir)? {
|
|
817
|
-
let entry = entry?;
|
|
818
|
-
let name = entry.file_name().to_string_lossy().to_string();
|
|
819
|
-
if name.ends_with(".closed.json") {
|
|
820
|
-
closed_files.push(entry.path());
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
closed_files.sort();
|
|
825
|
-
|
|
826
|
-
let mut removed = 0u32;
|
|
827
|
-
while closed_files.len() > limit as usize {
|
|
828
|
-
if let Some(oldest) = closed_files.first() {
|
|
829
|
-
std::fs::remove_file(oldest)
|
|
830
|
-
.with_context(|| format!("Failed to remove old session: {}", oldest.display()))?;
|
|
831
|
-
closed_files.remove(0);
|
|
832
|
-
removed += 1;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
|
|
836
|
-
Ok(removed)
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
pub fn write_session_with_status(
|
|
840
|
-
sessions_dir: &Path,
|
|
841
|
-
data: &SessionData,
|
|
842
|
-
status: &str,
|
|
843
|
-
) -> Result<PathBuf> {
|
|
844
|
-
let mut data = data.clone();
|
|
845
|
-
if data.id.is_none() {
|
|
846
|
-
data.id = Some(generate_session_id());
|
|
847
|
-
}
|
|
848
|
-
|
|
849
|
-
let ts_part = compact_timestamp(&data);
|
|
850
|
-
let base = generate_session_filename(&data.summary, &ts_part);
|
|
851
|
-
let filename = format!("{base}.{status}.json");
|
|
852
|
-
let path = sessions_dir.join(&filename);
|
|
853
|
-
|
|
854
|
-
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
855
|
-
crate::storage::atomic_write(&path, content.as_bytes())
|
|
856
|
-
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
857
|
-
|
|
858
|
-
Ok(path)
|
|
859
|
-
}
|