handoff-mcp-server 0.4.0 → 0.6.1
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/bin/handoff-mcp-bin +0 -0
- package/bin/handoff-mcp.js +0 -0
- package/package.json +1 -1
- package/src/mcp/handlers/dashboard.rs +4 -2
- package/src/mcp/handlers/import_context.rs +10 -5
- package/src/mcp/handlers/load_context.rs +50 -21
- package/src/mcp/handlers/refer.rs +157 -2
- package/src/mcp/handlers/save_context.rs +153 -9
- package/src/mcp/resources.rs +3 -2
- package/src/mcp/router.rs +1 -1
- package/src/mcp/tools.rs +49 -25
- package/src/storage/sessions.rs +161 -33
- package/tests/storage_sessions.rs +189 -25
- package/tests/tool_import_context.rs +1 -1
- package/tests/tool_referrals.rs +162 -0
- 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
|
}
|
|
@@ -366,13 +383,15 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
366
383
|
"summary": { "type": "string", "description": "One-line summary (required)" },
|
|
367
384
|
"decisions": {
|
|
368
385
|
"type": "array",
|
|
386
|
+
"description": "Decisions made during this session",
|
|
369
387
|
"items": {
|
|
370
388
|
"type": "object",
|
|
371
389
|
"properties": {
|
|
372
|
-
"decision": { "type": "string" },
|
|
373
|
-
"reason": { "type": "string" },
|
|
390
|
+
"decision": { "type": "string", "description": "What was decided" },
|
|
391
|
+
"reason": { "type": "string", "description": "Why this decision was made" },
|
|
374
392
|
"confidence": {
|
|
375
393
|
"type": "string",
|
|
394
|
+
"description": "confirmed = verified; estimated = reasoned but not verified; unverified = hypothesis",
|
|
376
395
|
"enum": ["confirmed", "estimated", "unverified"]
|
|
377
396
|
}
|
|
378
397
|
},
|
|
@@ -381,52 +400,57 @@ pub fn all_tool_definitions() -> Vec<ToolDefinition> {
|
|
|
381
400
|
},
|
|
382
401
|
"blockers": {
|
|
383
402
|
"type": "array",
|
|
403
|
+
"description": "Issues preventing progress",
|
|
384
404
|
"items": { "type": "string" }
|
|
385
405
|
},
|
|
386
406
|
"checklist": {
|
|
387
407
|
"type": "array",
|
|
408
|
+
"description": "Verification items for the next session or user",
|
|
388
409
|
"items": {
|
|
389
410
|
"type": "object",
|
|
390
411
|
"properties": {
|
|
391
|
-
"item": { "type": "string" },
|
|
392
|
-
"checked": { "type": "boolean" },
|
|
393
|
-
"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"] }
|
|
394
415
|
},
|
|
395
416
|
"required": ["item"]
|
|
396
417
|
}
|
|
397
418
|
},
|
|
398
419
|
"handoff_notes": {
|
|
399
420
|
"type": "array",
|
|
421
|
+
"description": "Notes for the next session. Include at least one 'suggestion' with a concrete next action.",
|
|
400
422
|
"items": {
|
|
401
423
|
"type": "object",
|
|
402
424
|
"properties": {
|
|
403
|
-
"note": { "type": "string" },
|
|
404
|
-
"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"] }
|
|
405
427
|
},
|
|
406
428
|
"required": ["note"]
|
|
407
429
|
}
|
|
408
430
|
},
|
|
409
431
|
"references": {
|
|
410
432
|
"type": "array",
|
|
433
|
+
"description": "Links to related docs, issues, MRs (not active work files)",
|
|
411
434
|
"items": {
|
|
412
435
|
"type": "object",
|
|
413
436
|
"properties": {
|
|
414
|
-
"label": { "type": "string" },
|
|
415
|
-
"uri": { "type": "string" },
|
|
416
|
-
"type": { "type": "string", "enum": ["file", "issue", "mr", "wiki", "doc", "url"] },
|
|
417
|
-
"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" }
|
|
418
441
|
},
|
|
419
442
|
"required": ["label", "uri"]
|
|
420
443
|
}
|
|
421
444
|
},
|
|
422
445
|
"context_pointers": {
|
|
423
446
|
"type": "array",
|
|
447
|
+
"description": "Files the next session should open first to resume work (not completed files)",
|
|
424
448
|
"items": {
|
|
425
449
|
"type": "object",
|
|
426
450
|
"properties": {
|
|
427
|
-
"path": { "type": "string" },
|
|
428
|
-
"reason": { "type": "string" },
|
|
429
|
-
"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')" }
|
|
430
454
|
},
|
|
431
455
|
"required": ["path"]
|
|
432
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
|
+
}
|