handoff-mcp-server 0.10.0 → 0.12.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 +2 -1
- package/Cargo.toml +2 -1
- package/README.md +112 -32
- package/package.json +2 -1
- package/skills/handoff/SKILL.md +230 -0
- package/skills/handoff-import/SKILL.md +238 -0
- package/skills/handoff-load/SKILL.md +37 -0
- package/skills/handoff-refer/SKILL.md +71 -0
- package/src/mcp/handlers/assignees.rs +254 -0
- package/src/mcp/handlers/auto_schedule.rs +498 -0
- package/src/mcp/handlers/bulk_update.rs +115 -0
- package/src/mcp/handlers/calendar.rs +196 -0
- package/src/mcp/handlers/capacity.rs +331 -0
- package/src/mcp/handlers/config.rs +229 -67
- package/src/mcp/handlers/config_crud.rs +183 -0
- package/src/mcp/handlers/get_session.rs +48 -0
- package/src/mcp/handlers/get_task.rs +1 -0
- package/src/mcp/handlers/import_context.rs +7 -0
- package/src/mcp/handlers/list_sessions.rs +129 -0
- package/src/mcp/handlers/list_tasks.rs +76 -10
- package/src/mcp/handlers/load_context.rs +16 -0
- package/src/mcp/handlers/log_time.rs +70 -0
- package/src/mcp/handlers/metrics.rs +196 -0
- package/src/mcp/handlers/milestones.rs +102 -0
- package/src/mcp/handlers/mod.rs +30 -0
- package/src/mcp/handlers/referrals.rs +20 -1
- package/src/mcp/handlers/update_session.rs +1 -1
- package/src/mcp/handlers/update_task.rs +99 -6
- package/src/mcp/tools.rs +357 -3
- package/src/storage/config.rs +162 -1
- package/src/storage/mod.rs +42 -0
- package/src/storage/referrals.rs +39 -1
- package/src/storage/sessions.rs +95 -22
- package/src/storage/tasks.rs +122 -2
package/src/storage/config.rs
CHANGED
|
@@ -11,6 +11,27 @@ pub struct Config {
|
|
|
11
11
|
pub settings: SettingsConfig,
|
|
12
12
|
#[serde(default)]
|
|
13
13
|
pub dashboard: DashboardConfig,
|
|
14
|
+
/// Project-level start timestamp (pre-start mode). RFC3339 string.
|
|
15
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
16
|
+
pub started_at: Option<String>,
|
|
17
|
+
/// Scheduling mode: "manual" or "auto".
|
|
18
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
19
|
+
pub schedule_mode: Option<String>,
|
|
20
|
+
/// Project-level label vocabulary.
|
|
21
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
22
|
+
pub labels: Vec<String>,
|
|
23
|
+
#[serde(default, skip_serializing_if = "CalendarConfig::is_empty")]
|
|
24
|
+
pub calendar: CalendarConfig,
|
|
25
|
+
/// Team members keyed by stable assignee key.
|
|
26
|
+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
27
|
+
pub assignees: HashMap<String, AssigneeConfig>,
|
|
28
|
+
/// Milestones keyed by milestone name.
|
|
29
|
+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
30
|
+
pub milestones: HashMap<String, MilestoneConfig>,
|
|
31
|
+
#[serde(default, skip_serializing_if = "GanttViewConfig::is_empty")]
|
|
32
|
+
pub gantt_view: GanttViewConfig,
|
|
33
|
+
#[serde(default, skip_serializing_if = "EffortBudgetConfig::is_empty")]
|
|
34
|
+
pub effort_budget: EffortBudgetConfig,
|
|
14
35
|
}
|
|
15
36
|
|
|
16
37
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
@@ -28,6 +49,13 @@ pub struct SettingsConfig {
|
|
|
28
49
|
pub done_task_limit: u32,
|
|
29
50
|
#[serde(default = "default_auto_git_summary")]
|
|
30
51
|
pub auto_git_summary: bool,
|
|
52
|
+
/// Require `estimate_hours` when creating/updating leaf tasks. Default true.
|
|
53
|
+
#[serde(default = "default_require_estimate_hours")]
|
|
54
|
+
pub require_estimate_hours: bool,
|
|
55
|
+
/// Multiplier applied to AI-entered `estimate_hours` to derive the
|
|
56
|
+
/// adjusted (AI-effort) estimate at aggregation time. Default 0.2.
|
|
57
|
+
#[serde(default = "default_ai_estimate_multiplier")]
|
|
58
|
+
pub ai_estimate_multiplier: f64,
|
|
31
59
|
#[serde(default)]
|
|
32
60
|
pub context_files: Vec<String>,
|
|
33
61
|
#[serde(default)]
|
|
@@ -42,6 +70,121 @@ pub struct DashboardConfig {
|
|
|
42
70
|
pub exclude_patterns: Vec<String>,
|
|
43
71
|
}
|
|
44
72
|
|
|
73
|
+
/// Project-wide working calendar. Mirrors VSCode `CalendarConfig`.
|
|
74
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
75
|
+
pub struct CalendarConfig {
|
|
76
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
77
|
+
pub work_hours_per_day: Option<f64>,
|
|
78
|
+
/// Weekday numbers (0=Sun..6=Sat) that are non-working.
|
|
79
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
80
|
+
pub closed_weekdays: Vec<u32>,
|
|
81
|
+
/// Specific YYYY-MM-DD dates that are non-working (override weekdays).
|
|
82
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
83
|
+
pub closed_dates: Vec<String>,
|
|
84
|
+
/// Specific YYYY-MM-DD dates that are working even if normally closed.
|
|
85
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
86
|
+
pub open_dates: Vec<String>,
|
|
87
|
+
/// Per-weekday / per-date working-hour overrides. Key = weekday name or YYYY-MM-DD.
|
|
88
|
+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
89
|
+
pub day_hours: HashMap<String, f64>,
|
|
90
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
91
|
+
pub schedule_mode: Option<String>,
|
|
92
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
93
|
+
pub overwork_limit_percent: Option<f64>,
|
|
94
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
95
|
+
pub max_utilization: Option<f64>,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
impl CalendarConfig {
|
|
99
|
+
pub fn is_empty(&self) -> bool {
|
|
100
|
+
self.work_hours_per_day.is_none()
|
|
101
|
+
&& self.closed_weekdays.is_empty()
|
|
102
|
+
&& self.closed_dates.is_empty()
|
|
103
|
+
&& self.open_dates.is_empty()
|
|
104
|
+
&& self.day_hours.is_empty()
|
|
105
|
+
&& self.schedule_mode.is_none()
|
|
106
|
+
&& self.overwork_limit_percent.is_none()
|
|
107
|
+
&& self.max_utilization.is_none()
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/// A single team member's configuration. Mirrors VSCode `AssigneeConfig`.
|
|
112
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
113
|
+
pub struct AssigneeConfig {
|
|
114
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
115
|
+
pub display_name: Option<String>,
|
|
116
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
117
|
+
pub color: Option<String>,
|
|
118
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
119
|
+
pub work_hours_per_day: Option<f64>,
|
|
120
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
121
|
+
pub closed_weekdays: Vec<u32>,
|
|
122
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
123
|
+
pub closed_dates: Vec<String>,
|
|
124
|
+
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
125
|
+
pub open_dates: Vec<String>,
|
|
126
|
+
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
|
|
127
|
+
pub day_hours: HashMap<String, f64>,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/// A milestone definition. Mirrors VSCode `MilestoneConfig`.
|
|
131
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
132
|
+
pub struct MilestoneConfig {
|
|
133
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
134
|
+
pub date: Option<String>,
|
|
135
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
136
|
+
pub color: Option<String>,
|
|
137
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
138
|
+
pub description: Option<String>,
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/// Gantt view UI settings. Mirrors VSCode `GanttViewSettings`.
|
|
142
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
143
|
+
pub struct GanttViewConfig {
|
|
144
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
145
|
+
pub sort: Option<String>,
|
|
146
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
147
|
+
pub zoom: Option<String>,
|
|
148
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
149
|
+
pub mode: Option<String>,
|
|
150
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
151
|
+
pub group_by_milestone: Option<bool>,
|
|
152
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
153
|
+
pub group_by_assignee: Option<bool>,
|
|
154
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
155
|
+
pub show_workload: Option<bool>,
|
|
156
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
157
|
+
pub filter_assignee: Option<String>,
|
|
158
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
159
|
+
pub workload_view: Option<String>,
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
impl GanttViewConfig {
|
|
163
|
+
pub fn is_empty(&self) -> bool {
|
|
164
|
+
self.sort.is_none()
|
|
165
|
+
&& self.zoom.is_none()
|
|
166
|
+
&& self.mode.is_none()
|
|
167
|
+
&& self.group_by_milestone.is_none()
|
|
168
|
+
&& self.group_by_assignee.is_none()
|
|
169
|
+
&& self.show_workload.is_none()
|
|
170
|
+
&& self.filter_assignee.is_none()
|
|
171
|
+
&& self.workload_view.is_none()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/// Effort budget. Mirrors VSCode `budgetTotalHours`.
|
|
176
|
+
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
177
|
+
pub struct EffortBudgetConfig {
|
|
178
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
179
|
+
pub total_hours: Option<f64>,
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
impl EffortBudgetConfig {
|
|
183
|
+
pub fn is_empty(&self) -> bool {
|
|
184
|
+
self.total_hours.is_none()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
45
188
|
fn default_history_limit() -> u32 {
|
|
46
189
|
20
|
|
47
190
|
}
|
|
@@ -54,6 +197,14 @@ fn default_auto_git_summary() -> bool {
|
|
|
54
197
|
true
|
|
55
198
|
}
|
|
56
199
|
|
|
200
|
+
fn default_require_estimate_hours() -> bool {
|
|
201
|
+
true
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
fn default_ai_estimate_multiplier() -> f64 {
|
|
205
|
+
0.2
|
|
206
|
+
}
|
|
207
|
+
|
|
57
208
|
fn default_scan_dirs() -> Vec<String> {
|
|
58
209
|
vec!["~/pro/".to_string()]
|
|
59
210
|
}
|
|
@@ -64,6 +215,8 @@ impl Default for SettingsConfig {
|
|
|
64
215
|
history_limit: default_history_limit(),
|
|
65
216
|
done_task_limit: default_done_task_limit(),
|
|
66
217
|
auto_git_summary: default_auto_git_summary(),
|
|
218
|
+
require_estimate_hours: default_require_estimate_hours(),
|
|
219
|
+
ai_estimate_multiplier: default_ai_estimate_multiplier(),
|
|
67
220
|
context_files: Vec::new(),
|
|
68
221
|
custom_fields: HashMap::new(),
|
|
69
222
|
}
|
|
@@ -92,6 +245,14 @@ impl Config {
|
|
|
92
245
|
},
|
|
93
246
|
settings: SettingsConfig::default(),
|
|
94
247
|
dashboard: DashboardConfig::default(),
|
|
248
|
+
started_at: None,
|
|
249
|
+
schedule_mode: None,
|
|
250
|
+
labels: Vec::new(),
|
|
251
|
+
calendar: CalendarConfig::default(),
|
|
252
|
+
assignees: HashMap::new(),
|
|
253
|
+
milestones: HashMap::new(),
|
|
254
|
+
gantt_view: GanttViewConfig::default(),
|
|
255
|
+
effort_budget: EffortBudgetConfig::default(),
|
|
95
256
|
}
|
|
96
257
|
}
|
|
97
258
|
}
|
|
@@ -106,7 +267,7 @@ pub fn read_config(path: &Path) -> Result<Config> {
|
|
|
106
267
|
|
|
107
268
|
pub fn write_config(path: &Path, config: &Config) -> Result<()> {
|
|
108
269
|
let content = toml::to_string_pretty(config).context("Failed to serialize config")?;
|
|
109
|
-
|
|
270
|
+
crate::storage::atomic_write(path, content.as_bytes())
|
|
110
271
|
.with_context(|| format!("Failed to write config: {}", path.display()))?;
|
|
111
272
|
Ok(())
|
|
112
273
|
}
|
package/src/storage/mod.rs
CHANGED
|
@@ -8,6 +8,48 @@ use std::path::{Path, PathBuf};
|
|
|
8
8
|
|
|
9
9
|
use anyhow::{Context, Result};
|
|
10
10
|
|
|
11
|
+
/// Write `content` to `path` atomically: write to a sibling temp file, fsync,
|
|
12
|
+
/// then rename over the target. A rename within the same directory is atomic on
|
|
13
|
+
/// POSIX, so a concurrent reader never observes a partially-written file.
|
|
14
|
+
///
|
|
15
|
+
/// Used by every handoff write path (tasks, config, sessions, referrals) so that
|
|
16
|
+
/// the VSCode extension — which writes the same files — never reads torn data.
|
|
17
|
+
pub fn atomic_write(path: impl AsRef<Path>, content: &[u8]) -> Result<()> {
|
|
18
|
+
use std::io::Write;
|
|
19
|
+
|
|
20
|
+
let path = path.as_ref();
|
|
21
|
+
let dir = path.parent().ok_or_else(|| {
|
|
22
|
+
anyhow::anyhow!("Cannot determine parent directory for {}", path.display())
|
|
23
|
+
})?;
|
|
24
|
+
let file_name = path
|
|
25
|
+
.file_name()
|
|
26
|
+
.and_then(|n| n.to_str())
|
|
27
|
+
.ok_or_else(|| anyhow::anyhow!("Invalid file name for {}", path.display()))?;
|
|
28
|
+
|
|
29
|
+
// Unique-per-process temp name in the same directory (so rename is atomic).
|
|
30
|
+
let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
|
|
31
|
+
let tmp_path = dir.join(tmp_name);
|
|
32
|
+
|
|
33
|
+
let mut f = std::fs::File::create(&tmp_path)
|
|
34
|
+
.with_context(|| format!("Failed to create temp file {}", tmp_path.display()))?;
|
|
35
|
+
f.write_all(content)
|
|
36
|
+
.with_context(|| format!("Failed to write temp file {}", tmp_path.display()))?;
|
|
37
|
+
f.sync_all()
|
|
38
|
+
.with_context(|| format!("Failed to sync temp file {}", tmp_path.display()))?;
|
|
39
|
+
drop(f);
|
|
40
|
+
|
|
41
|
+
std::fs::rename(&tmp_path, path).with_context(|| {
|
|
42
|
+
// Best-effort cleanup so a failed rename doesn't leave a stray temp file.
|
|
43
|
+
let _ = std::fs::remove_file(&tmp_path);
|
|
44
|
+
format!(
|
|
45
|
+
"Failed to rename {} -> {}",
|
|
46
|
+
tmp_path.display(),
|
|
47
|
+
path.display()
|
|
48
|
+
)
|
|
49
|
+
})?;
|
|
50
|
+
Ok(())
|
|
51
|
+
}
|
|
52
|
+
|
|
11
53
|
pub fn expand_tilde(path: &str) -> String {
|
|
12
54
|
if let Some(rest) = path.strip_prefix("~/") {
|
|
13
55
|
if let Ok(home) = std::env::var("HOME") {
|
package/src/storage/referrals.rs
CHANGED
|
@@ -59,7 +59,7 @@ pub fn write_referral(referrals_dir: &Path, data: &ReferralData) -> Result<PathB
|
|
|
59
59
|
let file_path = referrals_dir.join(&filename);
|
|
60
60
|
|
|
61
61
|
let content = serde_json::to_string_pretty(data).context("Failed to serialize referral")?;
|
|
62
|
-
|
|
62
|
+
crate::storage::atomic_write(&file_path, content.as_bytes())
|
|
63
63
|
.with_context(|| format!("Failed to write referral: {}", file_path.display()))?;
|
|
64
64
|
|
|
65
65
|
Ok(file_path)
|
|
@@ -160,6 +160,44 @@ pub fn change_referral_status(
|
|
|
160
160
|
Ok(())
|
|
161
161
|
}
|
|
162
162
|
|
|
163
|
+
/// Read a single referral's full data by ID, returning the data and its status.
|
|
164
|
+
/// Searches every status (open / acknowledged / resolved). Matches by exact ID
|
|
165
|
+
/// first, then falls back to a unique prefix match for convenience.
|
|
166
|
+
pub fn read_referral_by_id(
|
|
167
|
+
referrals_dir: &Path,
|
|
168
|
+
referral_id: &str,
|
|
169
|
+
) -> Result<Option<(ReferralData, String)>> {
|
|
170
|
+
if !referrals_dir.exists() {
|
|
171
|
+
return Ok(None);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let mut prefix_matches: Vec<(ReferralData, String)> = Vec::new();
|
|
175
|
+
|
|
176
|
+
for entry in std::fs::read_dir(referrals_dir)? {
|
|
177
|
+
let entry = entry?;
|
|
178
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
179
|
+
if let Some(status) = parse_referral_status(&name) {
|
|
180
|
+
let content = std::fs::read_to_string(entry.path())?;
|
|
181
|
+
if let Ok(data) = serde_json::from_str::<ReferralData>(&content) {
|
|
182
|
+
if data.id == referral_id {
|
|
183
|
+
return Ok(Some((data, status)));
|
|
184
|
+
}
|
|
185
|
+
if data.id.starts_with(referral_id) {
|
|
186
|
+
prefix_matches.push((data, status));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
match prefix_matches.len() {
|
|
193
|
+
0 => Ok(None),
|
|
194
|
+
1 => Ok(Some(prefix_matches.into_iter().next().unwrap())),
|
|
195
|
+
n => anyhow::bail!(
|
|
196
|
+
"Ambiguous referral id prefix '{referral_id}' matches {n} referrals; use the full id"
|
|
197
|
+
),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
163
201
|
pub fn find_referral_file(
|
|
164
202
|
referrals_dir: &Path,
|
|
165
203
|
referral_id: &str,
|
package/src/storage/sessions.rs
CHANGED
|
@@ -109,6 +109,10 @@ fn synthesize_id_from_filename(filename: &str) -> String {
|
|
|
109
109
|
}
|
|
110
110
|
}
|
|
111
111
|
|
|
112
|
+
fn ids_match(candidate: &str, query: &str) -> bool {
|
|
113
|
+
candidate == query || candidate.starts_with(query) || query.starts_with(candidate)
|
|
114
|
+
}
|
|
115
|
+
|
|
112
116
|
pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<PathBuf> {
|
|
113
117
|
let mut data = data.clone();
|
|
114
118
|
if data.id.is_none() {
|
|
@@ -121,7 +125,7 @@ pub fn write_open_session(sessions_dir: &Path, data: &SessionData) -> Result<Pat
|
|
|
121
125
|
let path = sessions_dir.join(&filename);
|
|
122
126
|
|
|
123
127
|
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
124
|
-
|
|
128
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
125
129
|
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
126
130
|
|
|
127
131
|
Ok(path)
|
|
@@ -165,6 +169,40 @@ pub fn read_active_sessions(sessions_dir: &Path) -> Result<Vec<SessionData>> {
|
|
|
165
169
|
read_sessions_by_status(sessions_dir, "active")
|
|
166
170
|
}
|
|
167
171
|
|
|
172
|
+
/// Append `decision` (a decision object: {decision, reason?, confidence?}) to the
|
|
173
|
+
/// `decisions` list of every active session file. Returns the number of sessions
|
|
174
|
+
/// updated. Used by tools like auto_schedule to record applied changes so the
|
|
175
|
+
/// audit trail lives with the session, not just the tool response.
|
|
176
|
+
pub fn append_decision_to_active_sessions(
|
|
177
|
+
sessions_dir: &Path,
|
|
178
|
+
decision: serde_json::Value,
|
|
179
|
+
) -> Result<usize> {
|
|
180
|
+
let suffix = ".active.json";
|
|
181
|
+
if !sessions_dir.exists() {
|
|
182
|
+
return Ok(0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
let mut count = 0;
|
|
186
|
+
for entry in std::fs::read_dir(sessions_dir)? {
|
|
187
|
+
let entry = entry?;
|
|
188
|
+
let name = entry.file_name().to_string_lossy().to_string();
|
|
189
|
+
if !name.ends_with(suffix) {
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
let path = entry.path();
|
|
193
|
+
let content = std::fs::read_to_string(&path)
|
|
194
|
+
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
195
|
+
let mut data: SessionData = serde_json::from_str(&content)
|
|
196
|
+
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
197
|
+
data.decisions.push(decision.clone());
|
|
198
|
+
let updated = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
199
|
+
crate::storage::atomic_write(&path, updated.as_bytes())
|
|
200
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
201
|
+
count += 1;
|
|
202
|
+
}
|
|
203
|
+
Ok(count)
|
|
204
|
+
}
|
|
205
|
+
|
|
168
206
|
fn transition_sessions(sessions_dir: &Path, from: &str, to: &str) -> Result<Vec<PathBuf>> {
|
|
169
207
|
let mut transitioned = Vec::new();
|
|
170
208
|
let from_suffix = format!(".{from}.json");
|
|
@@ -206,6 +244,8 @@ fn transition_session_by_id(
|
|
|
206
244
|
return Ok(None);
|
|
207
245
|
}
|
|
208
246
|
|
|
247
|
+
let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
|
|
248
|
+
|
|
209
249
|
for entry in std::fs::read_dir(sessions_dir)? {
|
|
210
250
|
let entry = entry?;
|
|
211
251
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
@@ -219,25 +259,39 @@ fn transition_session_by_id(
|
|
|
219
259
|
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
220
260
|
|
|
221
261
|
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
222
|
-
let
|
|
262
|
+
let resolved_id = if file_id.is_empty() {
|
|
223
263
|
synthesize_id_from_filename(&name)
|
|
224
264
|
} else {
|
|
225
265
|
file_id
|
|
226
266
|
};
|
|
227
267
|
|
|
228
|
-
if
|
|
229
|
-
|
|
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));
|
|
268
|
+
if ids_match(&resolved_id, session_id) {
|
|
269
|
+
matches.push((entry.path(), name, resolved_id));
|
|
238
270
|
}
|
|
239
271
|
}
|
|
240
272
|
|
|
273
|
+
if matches.len() > 1 {
|
|
274
|
+
let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
|
|
275
|
+
anyhow::bail!(
|
|
276
|
+
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
277
|
+
session_id,
|
|
278
|
+
matches.len(),
|
|
279
|
+
candidates.join(", ")
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if let Some((path, name, _)) = matches.into_iter().next() {
|
|
284
|
+
let new_name = name.replace(&from_suffix, &to_suffix);
|
|
285
|
+
let new_path = sessions_dir.join(&new_name);
|
|
286
|
+
std::fs::rename(&path, &new_path).with_context(|| {
|
|
287
|
+
format!(
|
|
288
|
+
"Failed to transition session {from}->{to}: {}",
|
|
289
|
+
path.display()
|
|
290
|
+
)
|
|
291
|
+
})?;
|
|
292
|
+
return Ok(Some(new_path));
|
|
293
|
+
}
|
|
294
|
+
|
|
241
295
|
Ok(None)
|
|
242
296
|
}
|
|
243
297
|
|
|
@@ -329,6 +383,8 @@ fn find_and_update_active_session(
|
|
|
329
383
|
return Ok(None);
|
|
330
384
|
}
|
|
331
385
|
|
|
386
|
+
let mut matches: Vec<(PathBuf, String, String)> = Vec::new();
|
|
387
|
+
|
|
332
388
|
for entry in std::fs::read_dir(sessions_dir)? {
|
|
333
389
|
let entry = entry?;
|
|
334
390
|
let name = entry.file_name().to_string_lossy().to_string();
|
|
@@ -338,41 +394,58 @@ fn find_and_update_active_session(
|
|
|
338
394
|
|
|
339
395
|
let content = std::fs::read_to_string(entry.path())
|
|
340
396
|
.with_context(|| format!("Failed to read session: {}", entry.path().display()))?;
|
|
341
|
-
let
|
|
397
|
+
let data: SessionData = serde_json::from_str(&content)
|
|
342
398
|
.with_context(|| format!("Failed to parse session: {}", entry.path().display()))?;
|
|
343
399
|
|
|
344
400
|
let file_id = data.id.as_deref().unwrap_or("").to_string();
|
|
345
|
-
let
|
|
401
|
+
let resolved_id = if file_id.is_empty() {
|
|
346
402
|
synthesize_id_from_filename(&name)
|
|
347
403
|
} else {
|
|
348
404
|
file_id
|
|
349
405
|
};
|
|
350
406
|
|
|
351
|
-
if
|
|
352
|
-
|
|
407
|
+
if ids_match(&resolved_id, session_id) {
|
|
408
|
+
matches.push((entry.path(), name, resolved_id));
|
|
353
409
|
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if matches.len() > 1 {
|
|
413
|
+
let candidates: Vec<&str> = matches.iter().map(|(_, _, id)| id.as_str()).collect();
|
|
414
|
+
anyhow::bail!(
|
|
415
|
+
"Ambiguous session_id '{}': matched {} sessions ({}). Provide a more specific ID.",
|
|
416
|
+
session_id,
|
|
417
|
+
matches.len(),
|
|
418
|
+
candidates.join(", ")
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if let Some((path, name, _)) = matches.into_iter().next() {
|
|
423
|
+
let content = std::fs::read_to_string(&path)
|
|
424
|
+
.with_context(|| format!("Failed to read session: {}", path.display()))?;
|
|
425
|
+
let mut data: SessionData = serde_json::from_str(&content)
|
|
426
|
+
.with_context(|| format!("Failed to parse session: {}", path.display()))?;
|
|
354
427
|
|
|
355
428
|
apply_session_updates(&mut data, updates);
|
|
356
429
|
|
|
357
430
|
let updated_content =
|
|
358
431
|
serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
359
|
-
|
|
360
|
-
.with_context(|| format!("Failed to write session: {}",
|
|
432
|
+
crate::storage::atomic_write(&path, updated_content.as_bytes())
|
|
433
|
+
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
361
434
|
|
|
362
435
|
if let Some(target_status) = transition_to {
|
|
363
436
|
let target_suffix = format!(".{target_status}.json");
|
|
364
437
|
let new_name = name.replace(suffix, &target_suffix);
|
|
365
438
|
let new_path = sessions_dir.join(&new_name);
|
|
366
|
-
std::fs::rename(
|
|
439
|
+
std::fs::rename(&path, &new_path).with_context(|| {
|
|
367
440
|
format!(
|
|
368
441
|
"Failed to transition session active->{target_status}: {}",
|
|
369
|
-
|
|
442
|
+
path.display()
|
|
370
443
|
)
|
|
371
444
|
})?;
|
|
372
445
|
return Ok(Some(new_path));
|
|
373
446
|
}
|
|
374
447
|
|
|
375
|
-
return Ok(Some(
|
|
448
|
+
return Ok(Some(path));
|
|
376
449
|
}
|
|
377
450
|
|
|
378
451
|
Ok(None)
|
|
@@ -440,7 +513,7 @@ pub fn write_session_with_status(
|
|
|
440
513
|
let path = sessions_dir.join(&filename);
|
|
441
514
|
|
|
442
515
|
let content = serde_json::to_string_pretty(&data).context("Failed to serialize session")?;
|
|
443
|
-
|
|
516
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
444
517
|
.with_context(|| format!("Failed to write session: {}", path.display()))?;
|
|
445
518
|
|
|
446
519
|
Ok(path)
|