handoff-mcp-server 0.3.0 → 0.6.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/Cargo.lock +1 -1
- package/Cargo.toml +1 -1
- package/package.json +1 -1
- package/src/mcp/handlers/dashboard.rs +4 -2
- package/src/mcp/handlers/get_task.rs +3 -0
- package/src/mcp/handlers/import_context.rs +39 -5
- package/src/mcp/handlers/list_tasks.rs +3 -0
- package/src/mcp/handlers/load_context.rs +50 -21
- package/src/mcp/handlers/save_context.rs +106 -9
- package/src/mcp/handlers/update_task.rs +47 -0
- package/src/mcp/resources.rs +3 -2
- package/src/mcp/router.rs +1 -1
- package/src/mcp/tools.rs +69 -25
- package/src/storage/sessions.rs +161 -33
- package/src/storage/tasks.rs +162 -1
- package/tests/storage_sessions.rs +189 -25
- package/tests/storage_tasks.rs +3 -0
- package/tests/tool_import_context.rs +1 -1
- package/tests/tool_sessions.rs +638 -5
package/src/mcp/tools.rs
CHANGED
|
@@ -35,6 +35,10 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
35
35
|
"project_dir": {
|
|
36
36
|
"type": "string",
|
|
37
37
|
"description": "Project directory path. Defaults to current working directory."
|
|
38
|
+
},
|
|
39
|
+
"session_id": {
|
|
40
|
+
"type": "string",
|
|
41
|
+
"description": "Session ID to activate and load. If omitted, activates all open sessions and returns the latest."
|
|
38
42
|
}
|
|
39
43
|
}
|
|
40
44
|
}),
|
|
@@ -53,16 +57,21 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
53
57
|
"type": "string",
|
|
54
58
|
"description": "One-line summary of this session"
|
|
55
59
|
},
|
|
60
|
+
"close_session_id": {
|
|
61
|
+
"type": "string",
|
|
62
|
+
"description": "Session ID to close. If omitted, all active and open sessions are closed (default behavior)."
|
|
63
|
+
},
|
|
56
64
|
"decisions": {
|
|
57
65
|
"type": "array",
|
|
58
66
|
"description": "Decisions made during this session",
|
|
59
67
|
"items": {
|
|
60
68
|
"type": "object",
|
|
61
69
|
"properties": {
|
|
62
|
-
"decision": { "type": "string" },
|
|
63
|
-
"reason": { "type": "string" },
|
|
70
|
+
"decision": { "type": "string", "description": "What was decided" },
|
|
71
|
+
"reason": { "type": "string", "description": "Why this decision was made" },
|
|
64
72
|
"confidence": {
|
|
65
73
|
"type": "string",
|
|
74
|
+
"description": "confirmed = verified by testing/evidence; estimated = reasoned but not verified; unverified = hypothesis needing validation",
|
|
66
75
|
"enum": ["confirmed", "estimated", "unverified"]
|
|
67
76
|
}
|
|
68
77
|
},
|
|
@@ -71,17 +80,20 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
71
80
|
},
|
|
72
81
|
"blockers": {
|
|
73
82
|
"type": "array",
|
|
83
|
+
"description": "Issues preventing progress. The next session should address these before starting new work.",
|
|
74
84
|
"items": { "type": "string" }
|
|
75
85
|
},
|
|
76
86
|
"checklist": {
|
|
77
87
|
"type": "array",
|
|
88
|
+
"description": "Verification items for the next session or user. Mark completed items as checked:true before saving.",
|
|
78
89
|
"items": {
|
|
79
90
|
"type": "object",
|
|
80
91
|
"properties": {
|
|
81
|
-
"item": { "type": "string" },
|
|
82
|
-
"checked": { "type": "boolean" },
|
|
92
|
+
"item": { "type": "string", "description": "What to verify or confirm" },
|
|
93
|
+
"checked": { "type": "boolean", "description": "true if already verified, false if pending" },
|
|
83
94
|
"owner": {
|
|
84
95
|
"type": "string",
|
|
96
|
+
"description": "user = requires human action; ai = the next AI session should handle this",
|
|
85
97
|
"enum": ["user", "ai"]
|
|
86
98
|
}
|
|
87
99
|
},
|
|
@@ -90,12 +102,14 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
90
102
|
},
|
|
91
103
|
"handoff_notes": {
|
|
92
104
|
"type": "array",
|
|
105
|
+
"description": "Notes for the next session. Include at least one 'suggestion' with a concrete next action.",
|
|
93
106
|
"items": {
|
|
94
107
|
"type": "object",
|
|
95
108
|
"properties": {
|
|
96
|
-
"note": { "type": "string" },
|
|
109
|
+
"note": { "type": "string", "description": "The note content. For suggestions: state what is ALREADY DONE, then describe the concrete next action." },
|
|
97
110
|
"category": {
|
|
98
111
|
"type": "string",
|
|
112
|
+
"description": "caution = risks/rules the next session must respect; context = background info for decisions; suggestion = concrete next action the next session should execute first (at least one required)",
|
|
99
113
|
"enum": ["caution", "context", "suggestion"]
|
|
100
114
|
}
|
|
101
115
|
},
|
|
@@ -104,28 +118,31 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
104
118
|
},
|
|
105
119
|
"references": {
|
|
106
120
|
"type": "array",
|
|
121
|
+
"description": "Links to related docs, issues, MRs, or external resources for reference (not active work files — use context_pointers for those).",
|
|
107
122
|
"items": {
|
|
108
123
|
"type": "object",
|
|
109
124
|
"properties": {
|
|
110
|
-
"label": { "type": "string" },
|
|
111
|
-
"uri": { "type": "string" },
|
|
125
|
+
"label": { "type": "string", "description": "Human-readable label for this reference" },
|
|
126
|
+
"uri": { "type": "string", "description": "Path, URL, or identifier" },
|
|
112
127
|
"type": {
|
|
113
128
|
"type": "string",
|
|
129
|
+
"description": "file = project file; issue = issue tracker; mr = merge/pull request; wiki = wiki page; doc = design document; url = external URL",
|
|
114
130
|
"enum": ["file", "issue", "mr", "wiki", "doc", "url"]
|
|
115
131
|
},
|
|
116
|
-
"notes": { "type": "string" }
|
|
132
|
+
"notes": { "type": "string", "description": "Additional context (e.g. 'see section 3 for root cause analysis')" }
|
|
117
133
|
},
|
|
118
134
|
"required": ["label", "uri"]
|
|
119
135
|
}
|
|
120
136
|
},
|
|
121
137
|
"context_pointers": {
|
|
122
138
|
"type": "array",
|
|
139
|
+
"description": "Files the next session should open first to resume work. Point to files that NEED WORK, not completed files. For completed files, use a 'context' handoff_note instead.",
|
|
123
140
|
"items": {
|
|
124
141
|
"type": "object",
|
|
125
142
|
"properties": {
|
|
126
|
-
"path": { "type": "string" },
|
|
127
|
-
"reason": { "type": "string" },
|
|
128
|
-
"lines": { "type": "string" }
|
|
143
|
+
"path": { "type": "string", "description": "File path relative to project root" },
|
|
144
|
+
"reason": { "type": "string", "description": "Why the next session should read this (e.g. 'resume implementation here', 'needs review')" },
|
|
145
|
+
"lines": { "type": "string", "description": "Line range to focus on (e.g. '42-78')" }
|
|
129
146
|
},
|
|
130
147
|
"required": ["path"]
|
|
131
148
|
}
|
|
@@ -242,6 +259,26 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
242
259
|
},
|
|
243
260
|
"required": ["item"]
|
|
244
261
|
}
|
|
262
|
+
},
|
|
263
|
+
"schedule": {
|
|
264
|
+
"type": "object",
|
|
265
|
+
"description": "Schedule and effort tracking.",
|
|
266
|
+
"properties": {
|
|
267
|
+
"start_date": { "type": "string", "description": "YYYY-MM-DD" },
|
|
268
|
+
"due_date": { "type": "string", "description": "YYYY-MM-DD" },
|
|
269
|
+
"estimate_hours": { "type": "number" },
|
|
270
|
+
"actual_hours": { "type": "number" },
|
|
271
|
+
"milestone": { "type": "string" }
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
"dependencies": {
|
|
275
|
+
"type": "array",
|
|
276
|
+
"description": "Task IDs this task depends on. Circular dependencies are rejected.",
|
|
277
|
+
"items": { "type": "string" }
|
|
278
|
+
},
|
|
279
|
+
"order": {
|
|
280
|
+
"type": "integer",
|
|
281
|
+
"description": "Display order among siblings. 0-based, lower = higher priority."
|
|
245
282
|
}
|
|
246
283
|
},
|
|
247
284
|
},
|
|
@@ -346,13 +383,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
346
383
|
"summary": { "type": "string", "description": "One-line summary (required)" },
|
|
347
384
|
"decisions": {
|
|
348
385
|
"type": "array",
|
|
386
|
+
"description": "Decisions made during this session",
|
|
349
387
|
"items": {
|
|
350
388
|
"type": "object",
|
|
351
389
|
"properties": {
|
|
352
|
-
"decision": { "type": "string" },
|
|
353
|
-
"reason": { "type": "string" },
|
|
390
|
+
"decision": { "type": "string", "description": "What was decided" },
|
|
391
|
+
"reason": { "type": "string", "description": "Why this decision was made" },
|
|
354
392
|
"confidence": {
|
|
355
393
|
"type": "string",
|
|
394
|
+
"description": "confirmed = verified; estimated = reasoned but not verified; unverified = hypothesis",
|
|
356
395
|
"enum": ["confirmed", "estimated", "unverified"]
|
|
357
396
|
}
|
|
358
397
|
},
|
|
@@ -361,52 +400,57 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
361
400
|
},
|
|
362
401
|
"blockers": {
|
|
363
402
|
"type": "array",
|
|
403
|
+
"description": "Issues preventing progress",
|
|
364
404
|
"items": { "type": "string" }
|
|
365
405
|
},
|
|
366
406
|
"checklist": {
|
|
367
407
|
"type": "array",
|
|
408
|
+
"description": "Verification items for the next session or user",
|
|
368
409
|
"items": {
|
|
369
410
|
"type": "object",
|
|
370
411
|
"properties": {
|
|
371
|
-
"item": { "type": "string" },
|
|
372
|
-
"checked": { "type": "boolean" },
|
|
373
|
-
"owner": { "type": "string", "enum": ["user", "ai"] }
|
|
412
|
+
"item": { "type": "string", "description": "What to verify" },
|
|
413
|
+
"checked": { "type": "boolean", "description": "true if verified, false if pending" },
|
|
414
|
+
"owner": { "type": "string", "description": "user = human action; ai = next AI session", "enum": ["user", "ai"] }
|
|
374
415
|
},
|
|
375
416
|
"required": ["item"]
|
|
376
417
|
}
|
|
377
418
|
},
|
|
378
419
|
"handoff_notes": {
|
|
379
420
|
"type": "array",
|
|
421
|
+
"description": "Notes for the next session. Include at least one 'suggestion' with a concrete next action.",
|
|
380
422
|
"items": {
|
|
381
423
|
"type": "object",
|
|
382
424
|
"properties": {
|
|
383
|
-
"note": { "type": "string" },
|
|
384
|
-
"category": { "type": "string", "enum": ["caution", "context", "suggestion"] }
|
|
425
|
+
"note": { "type": "string", "description": "The note content. For suggestions: state what is done, then the next action." },
|
|
426
|
+
"category": { "type": "string", "description": "caution = risks/rules; context = background; suggestion = concrete next action (at least one required)", "enum": ["caution", "context", "suggestion"] }
|
|
385
427
|
},
|
|
386
428
|
"required": ["note"]
|
|
387
429
|
}
|
|
388
430
|
},
|
|
389
431
|
"references": {
|
|
390
432
|
"type": "array",
|
|
433
|
+
"description": "Links to related docs, issues, MRs (not active work files)",
|
|
391
434
|
"items": {
|
|
392
435
|
"type": "object",
|
|
393
436
|
"properties": {
|
|
394
|
-
"label": { "type": "string" },
|
|
395
|
-
"uri": { "type": "string" },
|
|
396
|
-
"type": { "type": "string", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
|
|
397
|
-
"notes": { "type": "string" }
|
|
437
|
+
"label": { "type": "string", "description": "Human-readable label" },
|
|
438
|
+
"uri": { "type": "string", "description": "Path, URL, or identifier" },
|
|
439
|
+
"type": { "type": "string", "description": "file/issue/mr/wiki/doc/url", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
|
|
440
|
+
"notes": { "type": "string", "description": "Additional context" }
|
|
398
441
|
},
|
|
399
442
|
"required": ["label", "uri"]
|
|
400
443
|
}
|
|
401
444
|
},
|
|
402
445
|
"context_pointers": {
|
|
403
446
|
"type": "array",
|
|
447
|
+
"description": "Files the next session should open first to resume work (not completed files)",
|
|
404
448
|
"items": {
|
|
405
449
|
"type": "object",
|
|
406
450
|
"properties": {
|
|
407
|
-
"path": { "type": "string" },
|
|
408
|
-
"reason": { "type": "string" },
|
|
409
|
-
"lines": { "type": "string" }
|
|
451
|
+
"path": { "type": "string", "description": "File path relative to project root" },
|
|
452
|
+
"reason": { "type": "string", "description": "Why to read this file" },
|
|
453
|
+
"lines": { "type": "string", "description": "Line range (e.g. '42-78')" }
|
|
410
454
|
},
|
|
411
455
|
"required": ["path"]
|
|
412
456
|
}
|
package/src/storage/sessions.rs
CHANGED
|
@@ -7,6 +7,8 @@ use serde_json::Value;
|
|
|
7
7
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
8
8
|
pub struct SessionData {
|
|
9
9
|
pub version: u32,
|
|
10
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
11
|
+
pub id: Option<String>,
|
|
10
12
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
11
13
|
pub ended_at: Option<String>,
|
|
12
14
|
pub summary: String,
|
|
@@ -32,6 +34,11 @@ pub struct SessionData {
|
|
|
32
34
|
pub environment: Option<Value>,
|
|
33
35
|
}
|
|
34
36
|
|
|
37
|
+
pub fn generate_session_id() -> String {
|
|
38
|
+
let now = chrono::Utc::now();
|
|
39
|
+
format!("s-{}", now.format("%Y%m%d-%H%M%S-%6f"))
|
|
40
|
+
}
|
|
41
|
+
|
|
35
42
|
pub fn generate_session_filename(summary: &str, timestamp: &str) -> String {
|
|
36
43
|
let slug = summary_to_slug(summary);
|
|
37
44
|
format!("{timestamp}-{slug}")
|
|
@@ -72,53 +79,57 @@ fn summary_to_slug(summary: &str) -> String {
|
|
|
72
79
|
}
|
|
73
80
|
}
|
|
74
81
|
|
|
75
|
-
|
|
82
|
+
fn compact_timestamp(data: &SessionData) -> String {
|
|
76
83
|
let timestamp = data.ended_at.as_deref().unwrap_or("00000000-000000");
|
|
77
84
|
let ts_compact = timestamp
|
|
78
85
|
.replace(['-', ':'], "")
|
|
79
86
|
.replace('T', "-")
|
|
80
87
|
.replace('Z', "");
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
if ts_compact.len() >= 15 {
|
|
89
|
+
ts_compact[..15].to_string()
|
|
83
90
|
} else {
|
|
84
|
-
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
let base = generate_session_filename(&data.summary, ts_part);
|
|
88
|
-
let filename = format!("{base}.active.json");
|
|
89
|
-
let path = sessions_dir.join(&filename);
|
|
90
|
-
|
|
91
|
-
let content = serde_json::to_string_pretty(data).context("Failed to serialize session")?;
|
|
92
|
-
std::fs::write(&path, content)
|
|
93
|
-
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
94
|
-
|
|
95
|
-
Ok(path)
|
|
91
|
+
ts_compact
|
|
92
|
+
}
|
|
96
93
|
}
|
|
97
94
|
|
|
98
|
-
|
|
99
|
-
|
|
95
|
+
fn synthesize_id_from_filename(filename: &str) -> String {
|
|
96
|
+
// Old format: YYYYMMDD-HHMMSS-slug.status.json
|
|
97
|
+
// Extract timestamp part and create s-YYYYMMDD-HHMMSS-000000
|
|
98
|
+
let base = filename
|
|
99
|
+
.rsplit_once('.')
|
|
100
|
+
.and_then(|(rest, _)| rest.rsplit_once('.'))
|
|
101
|
+
.map(|(rest, _)| rest)
|
|
102
|
+
.unwrap_or(filename);
|
|
100
103
|
|
|
101
|
-
if
|
|
102
|
-
|
|
104
|
+
if base.len() >= 15 {
|
|
105
|
+
let ts = &base[..15]; // YYYYMMDD-HHMMSS
|
|
106
|
+
format!("s-{ts}-000000")
|
|
107
|
+
} else {
|
|
108
|
+
format!("s-{base}-000000")
|
|
103
109
|
}
|
|
110
|
+
}
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
let new_name = name.replace(".active.json", ".closed.json");
|
|
110
|
-
let new_path = sessions_dir.join(&new_name);
|
|
111
|
-
std::fs::rename(entry.path(), &new_path)
|
|
112
|
-
.with_context(|| format!("Failed to close session: {}", entry.path().display()))?;
|
|
113
|
-
closed.push(new_path);
|
|
114
|
-
}
|
|
112
|
+
pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
113
|
+
let mut data = data.clone();
|
|
114
|
+
if data.id.is_none() {
|
|
115
|
+
data.id = Some(generate_session_id());
|
|
115
116
|
}
|
|
116
117
|
|
|
117
|
-
|
|
118
|
+
let ts_part = compact_timestamp(&data);
|
|
119
|
+
let base = generate_session_filename(&data.summary, &ts_part);
|
|
120
|
+
let filename = format!("{base}.open.json");
|
|
121
|
+
let path = sessions_dir.join(&filename);
|
|
122
|
+
|
|
123
|
+
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
124
|
+
std::fs::write(&path, content)
|
|
125
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
126
|
+
|
|
127
|
+
Ok(path)
|
|
118
128
|
}
|
|
119
129
|
|
|
120
|
-
pub fn
|
|
130
|
+
pub fn read_sessions_by_status(sessions_dir: &Path, status: &str) -> Result<Vec<SessionData>> {
|
|
121
131
|
let mut sessions = Vec::new();
|
|
132
|
+
let suffix = format!(".{status}.json");
|
|
122
133
|
|
|
123
134
|
if !sessions_dir.exists() {
|
|
124
135
|
return Ok(sessions);
|
|
@@ -131,11 +142,14 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
131
142
|
|
|
132
143
|
for entry in entries {
|
|
133
144
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
134
|
-
if name.ends_with(
|
|
145
|
+
if name.ends_with(&suffix) {
|
|
135
146
|
let content = std::fs::read_to_string(entry.path())
|
|
136
147
|
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
137
|
-
let data: SessionData = serde_json::from_str(&content)
|
|
148
|
+
let mut data: SessionData = serde_json::from_str(&content)
|
|
138
149
|
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
150
|
+
if data.id.is_none() {
|
|
151
|
+
data.id = Some(synthesize_id_from_filename(&name));
|
|
152
|
+
}
|
|
139
153
|
sessions.push(data);
|
|
140
154
|
}
|
|
141
155
|
}
|
|
@@ -143,6 +157,114 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
143
157
|
Ok(sessions)
|
|
144
158
|
}
|
|
145
159
|
|
|
160
|
+
pub fn read_open_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
161
|
+
read_sessions_by_status(sessions_dir, "open")
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
165
|
+
read_sessions_by_status(sessions_dir, "active")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
|
|
169
|
+
let mut transitioned = Vec::new();
|
|
170
|
+
let from_suffix = format!(".{from}.json");
|
|
171
|
+
let to_suffix = format!(".{to}.json");
|
|
172
|
+
|
|
173
|
+
if !sessions_dir.exists() {
|
|
174
|
+
return Ok(transitioned);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
178
|
+
let entry = entry?;
|
|
179
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
180
|
+
if name.ends_with(&from_suffix) {
|
|
181
|
+
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
182
|
+
let new_path = sessions_dir.join(&new_name);
|
|
183
|
+
std::fs::rename(entry.path(), &new_path).with_context(|| {
|
|
184
|
+
format!(
|
|
185
|
+
"Failed to transition session {from}->{to}: {}",
|
|
186
|
+
entry.path().display()
|
|
187
|
+
)
|
|
188
|
+
})?;
|
|
189
|
+
transitioned.push(new_path);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
Ok(transitioned)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
fn transition_session_by_id(
|
|
197
|
+
sessions_dir: &Path,
|
|
198
|
+
session_id: &str,
|
|
199
|
+
from: &str,
|
|
200
|
+
to: &str,
|
|
201
|
+
) -> Result<Option<PathBuf>> {
|
|
202
|
+
let from_suffix = format!(".{from}.json");
|
|
203
|
+
let to_suffix = format!(".{to}.json");
|
|
204
|
+
|
|
205
|
+
if !sessions_dir.exists() {
|
|
206
|
+
return Ok(None);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
210
|
+
let entry = entry?;
|
|
211
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
212
|
+
if !name.ends_with(&from_suffix) {
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let content = std::fs::read_to_string(entry.path())
|
|
217
|
+
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
218
|
+
let data: SessionData = serde_json::from_str(&content)
|
|
219
|
+
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
220
|
+
|
|
221
|
+
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
222
|
+
let synthesized = if file_id.is_empty() {
|
|
223
|
+
synthesize_id_from_filename(&name)
|
|
224
|
+
} else {
|
|
225
|
+
file_id
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
if synthesized == session_id {
|
|
229
|
+
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
230
|
+
let new_path = sessions_dir.join(&new_name);
|
|
231
|
+
std::fs::rename(entry.path(), &new_path).with_context(|| {
|
|
232
|
+
format!(
|
|
233
|
+
"Failed to transition session {from}->{to}: {}",
|
|
234
|
+
entry.path().display()
|
|
235
|
+
)
|
|
236
|
+
})?;
|
|
237
|
+
return Ok(Some(new_path));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
Ok(None)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
pub fn activate_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
245
|
+
transition_sessions(sessions_dir, "open", "active")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
pub fn activate_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
249
|
+
transition_session_by_id(sessions_dir, session_id, "open", "active")
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
pub fn close_active_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
253
|
+
transition_sessions(sessions_dir, "active", "closed")
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
pub fn close_open_sessions(sessions_dir: &Path) -> Result<Vec<PathBuf>> {
|
|
257
|
+
transition_sessions(sessions_dir, "open", "closed")
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
pub fn close_session_by_id(sessions_dir: &Path, session_id: &str) -> Result<Option<PathBuf>> {
|
|
261
|
+
// Try active first, then open
|
|
262
|
+
if let Some(path) = transition_session_by_id(sessions_dir, session_id, "active", "closed")? {
|
|
263
|
+
return Ok(Some(path));
|
|
264
|
+
}
|
|
265
|
+
transition_session_by_id(sessions_dir, session_id, "open", "closed")
|
|
266
|
+
}
|
|
267
|
+
|
|
146
268
|
pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
147
269
|
if !sessions_dir.exists() {
|
|
148
270
|
return Ok(0);
|
|
@@ -172,3 +294,9 @@ pub fn enforce_history_limit(sessions_dir: &Path, limit: u32) -> Result<u32> {
|
|
|
172
294
|
|
|
173
295
|
Ok(removed)
|
|
174
296
|
}
|
|
297
|
+
|
|
298
|
+
// Backward-compatible aliases for tests and migration
|
|
299
|
+
#[doc(hidden)]
|
|
300
|
+
pub fn write_active_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
301
|
+
write_open_session(sessions_dir, data)
|
|
302
|
+
}
|