handoff-mcp-server 0.13.0 → 0.15.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 +25 -229
- package/Cargo.toml +2 -2
- package/README.md +116 -18
- package/package.json +1 -1
- package/scripts/handoff-memory-hook.py +6 -6
- package/skills/handoff/SKILL.md +17 -0
- package/src/cli.rs +551 -0
- package/src/lib.rs +2 -0
- package/src/main.rs +57 -0
- package/src/mcp/handlers/bulk_update.rs +1 -1
- package/src/mcp/handlers/check_criterion.rs +5 -6
- package/src/mcp/handlers/config.rs +38 -0
- package/src/mcp/handlers/get_task.rs +3 -6
- package/src/mcp/handlers/log_time.rs +3 -6
- package/src/mcp/handlers/mod.rs +8 -4
- package/src/mcp/handlers/timer.rs +529 -0
- package/src/mcp/handlers/update_task.rs +10 -21
- package/src/mcp/tools.rs +41 -4
- package/src/setup.rs +424 -0
- package/src/storage/config.rs +25 -0
- package/src/storage/tasks.rs +95 -4
|
@@ -45,7 +45,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
45
45
|
|
|
46
46
|
fn apply_single_update(tasks_dir: &std::path::Path, task_id: &str, update: &Value) -> Result<()> {
|
|
47
47
|
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
48
|
-
.ok_or_else(|| anyhow::anyhow!("
|
|
48
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
49
49
|
|
|
50
50
|
let (mut data, current_status) = read_task(&task_dir)?
|
|
51
51
|
.ok_or_else(|| anyhow::anyhow!("Task file not found for {task_id}"))?;
|
|
@@ -4,7 +4,9 @@ use serde_json::Value;
|
|
|
4
4
|
|
|
5
5
|
use super::resolve_project_dir;
|
|
6
6
|
use crate::storage::ensure_handoff_exists;
|
|
7
|
-
use crate::storage::tasks::{
|
|
7
|
+
use crate::storage::tasks::{
|
|
8
|
+
find_task_dir_by_id, find_task_file, read_task, suggest_task_id, write_task,
|
|
9
|
+
};
|
|
8
10
|
|
|
9
11
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
10
12
|
let project_dir = resolve_project_dir(arguments)?;
|
|
@@ -28,11 +30,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
28
30
|
.and_then(|v| v.as_bool())
|
|
29
31
|
.ok_or_else(|| anyhow::anyhow!("'checked' parameter is required (boolean)"))?;
|
|
30
32
|
|
|
31
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)
|
|
32
|
-
anyhow::anyhow!(
|
|
33
|
-
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
34
|
-
)
|
|
35
|
-
})?;
|
|
33
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
34
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
36
35
|
|
|
37
36
|
let (mut data, status) = read_task(&task_dir)?
|
|
38
37
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
@@ -53,6 +53,9 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
53
53
|
"settings.memory_query_limit",
|
|
54
54
|
"settings.memory_stale_days",
|
|
55
55
|
"settings.memory_injected_gc_days",
|
|
56
|
+
"settings.timer_provider",
|
|
57
|
+
"settings.timer_authority_ttl_secs",
|
|
58
|
+
"settings.timer_idle_timeout_minutes",
|
|
56
59
|
"dashboard.scan_dirs",
|
|
57
60
|
"dashboard.exclude_patterns",
|
|
58
61
|
"project.name",
|
|
@@ -179,6 +182,41 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
|
|
|
179
182
|
config.settings.memory_injected_gc_days = n;
|
|
180
183
|
applied.push(format!("settings.memory_injected_gc_days = {n}"));
|
|
181
184
|
}
|
|
185
|
+
"settings.timer_provider" => {
|
|
186
|
+
let Some(s) = value.as_str() else {
|
|
187
|
+
anyhow::bail!("settings.timer_provider must be a string");
|
|
188
|
+
};
|
|
189
|
+
let valid = ["auto", "vscode", "mcp", "off"];
|
|
190
|
+
if !valid.contains(&s) {
|
|
191
|
+
anyhow::bail!(
|
|
192
|
+
"settings.timer_provider must be one of: {}",
|
|
193
|
+
valid.join(", ")
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
config.settings.timer_provider = s.to_string();
|
|
197
|
+
applied.push(format!("settings.timer_provider = {s}"));
|
|
198
|
+
}
|
|
199
|
+
"settings.timer_authority_ttl_secs" => {
|
|
200
|
+
let Some(n) = value.as_u64() else {
|
|
201
|
+
anyhow::bail!(
|
|
202
|
+
"settings.timer_authority_ttl_secs must be a positive integer"
|
|
203
|
+
);
|
|
204
|
+
};
|
|
205
|
+
if n == 0 {
|
|
206
|
+
anyhow::bail!("settings.timer_authority_ttl_secs must be >= 1");
|
|
207
|
+
}
|
|
208
|
+
config.settings.timer_authority_ttl_secs = n;
|
|
209
|
+
applied.push(format!("settings.timer_authority_ttl_secs = {n}"));
|
|
210
|
+
}
|
|
211
|
+
"settings.timer_idle_timeout_minutes" => {
|
|
212
|
+
let Some(n) = value.as_u64() else {
|
|
213
|
+
anyhow::bail!(
|
|
214
|
+
"settings.timer_idle_timeout_minutes must be a non-negative integer"
|
|
215
|
+
);
|
|
216
|
+
};
|
|
217
|
+
config.settings.timer_idle_timeout_minutes = n;
|
|
218
|
+
applied.push(format!("settings.timer_idle_timeout_minutes = {n}"));
|
|
219
|
+
}
|
|
182
220
|
"dashboard.scan_dirs" => {
|
|
183
221
|
if let Some(arr) = value.as_array() {
|
|
184
222
|
config.dashboard.scan_dirs = arr
|
|
@@ -3,7 +3,7 @@ use serde_json::Value;
|
|
|
3
3
|
|
|
4
4
|
use super::resolve_project_dir;
|
|
5
5
|
use crate::storage::ensure_handoff_exists;
|
|
6
|
-
use crate::storage::tasks::{find_task_dir_by_id, read_task};
|
|
6
|
+
use crate::storage::tasks::{find_task_dir_by_id, read_task, suggest_task_id};
|
|
7
7
|
|
|
8
8
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
9
9
|
let project_dir = resolve_project_dir(arguments)?;
|
|
@@ -16,11 +16,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
16
16
|
.and_then(|v| v.as_str())
|
|
17
17
|
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
18
18
|
|
|
19
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)
|
|
20
|
-
anyhow::anyhow!(
|
|
21
|
-
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
22
|
-
)
|
|
23
|
-
})?;
|
|
19
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
20
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
24
21
|
|
|
25
22
|
let (data, status) = read_task(&task_dir)?
|
|
26
23
|
.ok_or_else(|| anyhow::anyhow!("Task file not found in {}", task_dir.display()))?;
|
|
@@ -6,7 +6,7 @@ use serde_json::Value;
|
|
|
6
6
|
|
|
7
7
|
use super::resolve_project_dir;
|
|
8
8
|
use crate::storage::ensure_handoff_exists;
|
|
9
|
-
use crate::storage::tasks::{find_task_dir_by_id, read_modify_write_task};
|
|
9
|
+
use crate::storage::tasks::{find_task_dir_by_id, read_modify_write_task, suggest_task_id};
|
|
10
10
|
|
|
11
11
|
pub fn handle(arguments: &Value) -> Result<String> {
|
|
12
12
|
let project_dir = resolve_project_dir(arguments)?;
|
|
@@ -28,11 +28,8 @@ pub fn handle(arguments: &Value) -> Result<String> {
|
|
|
28
28
|
anyhow::bail!("'hours' must be positive");
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)
|
|
32
|
-
anyhow::anyhow!(
|
|
33
|
-
"Task not found: {task_id}. Use handoff_list_tasks to see available task IDs."
|
|
34
|
-
)
|
|
35
|
-
})?;
|
|
31
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
32
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
36
33
|
|
|
37
34
|
// Capture the post-update values for the response message. read_modify_write
|
|
38
35
|
// re-runs the closure on a concurrent-write retry, so the cells always hold
|
package/src/mcp/handlers/mod.rs
CHANGED
|
@@ -21,6 +21,7 @@ pub mod milestones;
|
|
|
21
21
|
pub mod refer;
|
|
22
22
|
pub mod referrals;
|
|
23
23
|
pub mod save_context;
|
|
24
|
+
pub mod timer;
|
|
24
25
|
pub mod update_session;
|
|
25
26
|
pub mod update_task;
|
|
26
27
|
|
|
@@ -75,10 +76,13 @@ pub fn handle_tool_call(name: &str, arguments: &Value) -> JsonRpcResponse {
|
|
|
75
76
|
"handoff_update_calendar" => calendar::handle_update_calendar(arguments),
|
|
76
77
|
"handoff_update_labels" => calendar::handle_update_labels(arguments),
|
|
77
78
|
"handoff_start_project" => calendar::handle_start_project(arguments),
|
|
78
|
-
"
|
|
79
|
-
"
|
|
80
|
-
"
|
|
81
|
-
"
|
|
79
|
+
"handoff_memory_save" => memory::handle_save(arguments),
|
|
80
|
+
"handoff_memory_query" => memory::handle_query(arguments),
|
|
81
|
+
"handoff_memory_delete" => memory::handle_delete(arguments),
|
|
82
|
+
"handoff_memory_cleanup" => memory::handle_cleanup(arguments),
|
|
83
|
+
"handoff_timer_start" => timer::handle_start(arguments),
|
|
84
|
+
"handoff_timer_stop" => timer::handle_stop(arguments),
|
|
85
|
+
"handoff_timer_get_time" => timer::handle_get_time(arguments),
|
|
82
86
|
_ => Err(anyhow::anyhow!("Tool not implemented: {name}")),
|
|
83
87
|
};
|
|
84
88
|
|
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
use std::path::{Path, PathBuf};
|
|
2
|
+
|
|
3
|
+
use anyhow::{Context, Result};
|
|
4
|
+
use chrono::{DateTime, Utc};
|
|
5
|
+
use serde::{Deserialize, Serialize};
|
|
6
|
+
use serde_json::Value;
|
|
7
|
+
|
|
8
|
+
use super::resolve_project_dir;
|
|
9
|
+
use crate::storage::config::read_config;
|
|
10
|
+
use crate::storage::ensure_handoff_exists;
|
|
11
|
+
use crate::storage::tasks::{find_task_dir_by_id, read_modify_write_task, suggest_task_id};
|
|
12
|
+
|
|
13
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
14
|
+
pub struct Authority {
|
|
15
|
+
pub version: u32,
|
|
16
|
+
pub owner: String,
|
|
17
|
+
pub owner_instance: String,
|
|
18
|
+
pub heartbeat_at: String,
|
|
19
|
+
pub ttl_secs: u64,
|
|
20
|
+
pub updated_at: String,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
24
|
+
pub struct TimerEntry {
|
|
25
|
+
pub state: String,
|
|
26
|
+
pub elapsed_ms: u64,
|
|
27
|
+
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
28
|
+
pub started_at: Option<String>,
|
|
29
|
+
#[serde(default)]
|
|
30
|
+
pub paused_by_idle: bool,
|
|
31
|
+
#[serde(default)]
|
|
32
|
+
pub base_hours: f64,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
36
|
+
pub struct TimerState {
|
|
37
|
+
pub version: u32,
|
|
38
|
+
pub owner: String,
|
|
39
|
+
#[serde(default)]
|
|
40
|
+
pub timers: std::collections::HashMap<String, TimerEntry>,
|
|
41
|
+
pub updated_at: String,
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
45
|
+
pub struct TimerRequest {
|
|
46
|
+
pub version: u32,
|
|
47
|
+
pub id: String,
|
|
48
|
+
pub cmd: String,
|
|
49
|
+
pub task_id: String,
|
|
50
|
+
pub issued_by: String,
|
|
51
|
+
pub issued_at: String,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
fn timer_dir(handoff: &Path) -> PathBuf {
|
|
55
|
+
handoff.join("timer")
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn ensure_timer_dir(handoff: &Path) -> Result<PathBuf> {
|
|
59
|
+
let dir = timer_dir(handoff);
|
|
60
|
+
if !dir.exists() {
|
|
61
|
+
std::fs::create_dir_all(&dir)
|
|
62
|
+
.with_context(|| format!("Failed to create timer dir: {}", dir.display()))?;
|
|
63
|
+
}
|
|
64
|
+
let requests = dir.join("requests");
|
|
65
|
+
if !requests.exists() {
|
|
66
|
+
std::fs::create_dir_all(&requests)
|
|
67
|
+
.with_context(|| format!("Failed to create requests dir: {}", requests.display()))?;
|
|
68
|
+
}
|
|
69
|
+
Ok(dir)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
fn read_authority(timer: &Path) -> Result<Option<Authority>> {
|
|
73
|
+
let path = timer.join("authority.json");
|
|
74
|
+
if !path.exists() {
|
|
75
|
+
return Ok(None);
|
|
76
|
+
}
|
|
77
|
+
let content = std::fs::read_to_string(&path)
|
|
78
|
+
.with_context(|| format!("Failed to read authority: {}", path.display()))?;
|
|
79
|
+
let auth: Authority =
|
|
80
|
+
serde_json::from_str(&content).with_context(|| "Failed to parse authority.json")?;
|
|
81
|
+
Ok(Some(auth))
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
fn write_authority(timer: &Path, auth: &Authority) -> Result<()> {
|
|
85
|
+
let path = timer.join("authority.json");
|
|
86
|
+
let content =
|
|
87
|
+
serde_json::to_string_pretty(auth).with_context(|| "Failed to serialize authority")?;
|
|
88
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
fn read_state(timer: &Path) -> Result<Option<TimerState>> {
|
|
92
|
+
let path = timer.join("state.json");
|
|
93
|
+
if !path.exists() {
|
|
94
|
+
return Ok(None);
|
|
95
|
+
}
|
|
96
|
+
let content = std::fs::read_to_string(&path)
|
|
97
|
+
.with_context(|| format!("Failed to read state: {}", path.display()))?;
|
|
98
|
+
let state: TimerState =
|
|
99
|
+
serde_json::from_str(&content).with_context(|| "Failed to parse state.json")?;
|
|
100
|
+
Ok(Some(state))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
fn write_state(timer: &Path, state: &TimerState) -> Result<()> {
|
|
104
|
+
let path = timer.join("state.json");
|
|
105
|
+
let content =
|
|
106
|
+
serde_json::to_string_pretty(state).with_context(|| "Failed to serialize state")?;
|
|
107
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
fn read_modify_write_state<F>(timer: &Path, mut mutate: F) -> Result<TimerState>
|
|
111
|
+
where
|
|
112
|
+
F: FnMut(&mut TimerState) -> Result<()>,
|
|
113
|
+
{
|
|
114
|
+
const MAX_RETRIES: usize = 5;
|
|
115
|
+
for attempt in 0..=MAX_RETRIES {
|
|
116
|
+
let mut state = read_state(timer)?.unwrap_or_else(|| TimerState {
|
|
117
|
+
version: 1,
|
|
118
|
+
owner: "mcp".to_string(),
|
|
119
|
+
timers: std::collections::HashMap::new(),
|
|
120
|
+
updated_at: Utc::now().to_rfc3339(),
|
|
121
|
+
});
|
|
122
|
+
let snapshot = state.updated_at.clone();
|
|
123
|
+
mutate(&mut state)?;
|
|
124
|
+
state.updated_at = Utc::now().to_rfc3339();
|
|
125
|
+
|
|
126
|
+
let current = read_state(timer)?.map(|s| s.updated_at);
|
|
127
|
+
if current.as_deref() != Some(&snapshot) && current.is_some() {
|
|
128
|
+
if attempt == MAX_RETRIES {
|
|
129
|
+
anyhow::bail!("Concurrent modification of state.json after {MAX_RETRIES} retries");
|
|
130
|
+
}
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
write_state(timer, &state)?;
|
|
134
|
+
return Ok(state);
|
|
135
|
+
}
|
|
136
|
+
unreachable!()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
fn write_request(timer: &Path, req: &TimerRequest) -> Result<()> {
|
|
140
|
+
let path = timer.join("requests").join(format!("{}.json", req.id));
|
|
141
|
+
let content =
|
|
142
|
+
serde_json::to_string_pretty(req).with_context(|| "Failed to serialize request")?;
|
|
143
|
+
crate::storage::atomic_write(&path, content.as_bytes())
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
fn generate_request_id() -> String {
|
|
147
|
+
use std::sync::atomic::{AtomicU32, Ordering};
|
|
148
|
+
static SEQ: AtomicU32 = AtomicU32::new(0);
|
|
149
|
+
let now = Utc::now();
|
|
150
|
+
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
|
|
151
|
+
format!(
|
|
152
|
+
"{}-{}-{seq}",
|
|
153
|
+
now.format("%Y%m%d%H%M%S%9f"),
|
|
154
|
+
std::process::id()
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fn is_authority_alive(auth: &Authority) -> bool {
|
|
159
|
+
let Ok(heartbeat) = DateTime::parse_from_rfc3339(&auth.heartbeat_at) else {
|
|
160
|
+
return false;
|
|
161
|
+
};
|
|
162
|
+
let elapsed = Utc::now()
|
|
163
|
+
.signed_duration_since(heartbeat)
|
|
164
|
+
.num_seconds()
|
|
165
|
+
.unsigned_abs();
|
|
166
|
+
elapsed <= auth.ttl_secs
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
enum TimerProvider {
|
|
170
|
+
Vscode { authority_alive: bool },
|
|
171
|
+
McpFallback,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
fn determine_provider(
|
|
175
|
+
timer: &Path,
|
|
176
|
+
config_provider: &str,
|
|
177
|
+
ttl_secs: u64,
|
|
178
|
+
) -> Result<(TimerProvider, Option<Authority>)> {
|
|
179
|
+
match config_provider {
|
|
180
|
+
"off" => anyhow::bail!("Timer is disabled (timer_provider = off)"),
|
|
181
|
+
"vscode" => {
|
|
182
|
+
let auth = read_authority(timer)?;
|
|
183
|
+
let alive = auth.as_ref().map(is_authority_alive).unwrap_or(false);
|
|
184
|
+
Ok((
|
|
185
|
+
TimerProvider::Vscode {
|
|
186
|
+
authority_alive: alive,
|
|
187
|
+
},
|
|
188
|
+
auth,
|
|
189
|
+
))
|
|
190
|
+
}
|
|
191
|
+
"mcp" => {
|
|
192
|
+
let auth = read_authority(timer)?;
|
|
193
|
+
Ok((TimerProvider::McpFallback, auth))
|
|
194
|
+
}
|
|
195
|
+
_ => {
|
|
196
|
+
let auth = read_authority(timer)?;
|
|
197
|
+
match &auth {
|
|
198
|
+
Some(a) if a.owner == "vscode" && is_authority_alive(a) => Ok((
|
|
199
|
+
TimerProvider::Vscode {
|
|
200
|
+
authority_alive: true,
|
|
201
|
+
},
|
|
202
|
+
auth,
|
|
203
|
+
)),
|
|
204
|
+
Some(a) if a.owner == "mcp" && is_authority_alive(a) => {
|
|
205
|
+
Ok((TimerProvider::McpFallback, auth))
|
|
206
|
+
}
|
|
207
|
+
_ => {
|
|
208
|
+
let new_auth = Authority {
|
|
209
|
+
version: 1,
|
|
210
|
+
owner: "mcp".to_string(),
|
|
211
|
+
owner_instance: std::process::id().to_string(),
|
|
212
|
+
heartbeat_at: Utc::now().to_rfc3339(),
|
|
213
|
+
ttl_secs,
|
|
214
|
+
updated_at: Utc::now().to_rfc3339(),
|
|
215
|
+
};
|
|
216
|
+
write_authority(timer, &new_auth)?;
|
|
217
|
+
Ok((TimerProvider::McpFallback, Some(new_auth)))
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fn update_mcp_heartbeat(timer: &Path, ttl_secs: u64) -> Result<()> {
|
|
225
|
+
let now = Utc::now().to_rfc3339();
|
|
226
|
+
let auth = Authority {
|
|
227
|
+
version: 1,
|
|
228
|
+
owner: "mcp".to_string(),
|
|
229
|
+
owner_instance: std::process::id().to_string(),
|
|
230
|
+
heartbeat_at: now.clone(),
|
|
231
|
+
ttl_secs,
|
|
232
|
+
updated_at: now,
|
|
233
|
+
};
|
|
234
|
+
write_authority(timer, &auth)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
fn get_base_hours(tasks_dir: &Path, task_id: &str) -> Result<f64> {
|
|
238
|
+
let task_dir = find_task_dir_by_id(tasks_dir, task_id)?
|
|
239
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(tasks_dir, task_id)))?;
|
|
240
|
+
let (data, _status) = crate::storage::tasks::read_task(&task_dir)?
|
|
241
|
+
.ok_or_else(|| anyhow::anyhow!("Task file not found: {task_id}"))?;
|
|
242
|
+
Ok(data
|
|
243
|
+
.schedule
|
|
244
|
+
.as_ref()
|
|
245
|
+
.and_then(|s| s.actual_hours)
|
|
246
|
+
.unwrap_or(0.0))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
pub fn handle_start(arguments: &Value) -> Result<String> {
|
|
250
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
251
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
252
|
+
let tasks_dir = handoff.join("tasks");
|
|
253
|
+
let config_path = handoff.join("config.toml");
|
|
254
|
+
let config = read_config(&config_path)?;
|
|
255
|
+
|
|
256
|
+
let task_id = arguments
|
|
257
|
+
.get("task_id")
|
|
258
|
+
.and_then(|v| v.as_str())
|
|
259
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
260
|
+
|
|
261
|
+
find_task_dir_by_id(&tasks_dir, task_id)?
|
|
262
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
263
|
+
|
|
264
|
+
let timer = ensure_timer_dir(&handoff)?;
|
|
265
|
+
let ttl = config.settings.timer_authority_ttl_secs;
|
|
266
|
+
|
|
267
|
+
let (provider, _auth) = determine_provider(&timer, &config.settings.timer_provider, ttl)?;
|
|
268
|
+
|
|
269
|
+
match provider {
|
|
270
|
+
TimerProvider::Vscode { authority_alive } => {
|
|
271
|
+
let req = TimerRequest {
|
|
272
|
+
version: 1,
|
|
273
|
+
id: generate_request_id(),
|
|
274
|
+
cmd: "start".to_string(),
|
|
275
|
+
task_id: task_id.to_string(),
|
|
276
|
+
issued_by: "mcp".to_string(),
|
|
277
|
+
issued_at: Utc::now().to_rfc3339(),
|
|
278
|
+
};
|
|
279
|
+
write_request(&timer, &req)?;
|
|
280
|
+
let warning = if !authority_alive {
|
|
281
|
+
" WARNING: VSCode extension authority is stale or absent — the request may not be processed."
|
|
282
|
+
} else {
|
|
283
|
+
""
|
|
284
|
+
};
|
|
285
|
+
Ok(format!(
|
|
286
|
+
"Timer start delegated to VSCode extension for task {task_id} (request {}).{warning}",
|
|
287
|
+
req.id
|
|
288
|
+
))
|
|
289
|
+
}
|
|
290
|
+
TimerProvider::McpFallback => {
|
|
291
|
+
update_mcp_heartbeat(&timer, ttl)?;
|
|
292
|
+
|
|
293
|
+
let base = get_base_hours(&tasks_dir, task_id)?;
|
|
294
|
+
let task_id_owned = task_id.to_string();
|
|
295
|
+
let already_running = std::cell::Cell::new(None::<f64>);
|
|
296
|
+
read_modify_write_state(&timer, |state| {
|
|
297
|
+
if let Some(existing) = state.timers.get(&task_id_owned) {
|
|
298
|
+
if existing.state == "tracking" {
|
|
299
|
+
already_running
|
|
300
|
+
.set(Some(compute_live_elapsed_ms(existing) as f64 / 1000.0));
|
|
301
|
+
return Ok(());
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
let now = Utc::now().to_rfc3339();
|
|
305
|
+
state.timers.insert(
|
|
306
|
+
task_id_owned.clone(),
|
|
307
|
+
TimerEntry {
|
|
308
|
+
state: "tracking".to_string(),
|
|
309
|
+
elapsed_ms: 0,
|
|
310
|
+
started_at: Some(now),
|
|
311
|
+
paused_by_idle: false,
|
|
312
|
+
base_hours: base,
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
state.owner = "mcp".to_string();
|
|
316
|
+
Ok(())
|
|
317
|
+
})?;
|
|
318
|
+
|
|
319
|
+
if let Some(elapsed_secs) = already_running.get() {
|
|
320
|
+
return Ok(format!(
|
|
321
|
+
"Timer already running for task {task_id} (elapsed: {elapsed_secs:.1}s)"
|
|
322
|
+
));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
Ok(format!(
|
|
326
|
+
"MCP fallback timer started for task {task_id} (base_hours: {base:.2}h)"
|
|
327
|
+
))
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
fn compute_live_elapsed_ms(entry: &TimerEntry) -> u64 {
|
|
333
|
+
if entry.state != "tracking" {
|
|
334
|
+
return entry.elapsed_ms;
|
|
335
|
+
}
|
|
336
|
+
let Some(ref started) = entry.started_at else {
|
|
337
|
+
return entry.elapsed_ms;
|
|
338
|
+
};
|
|
339
|
+
let Ok(started_dt) = DateTime::parse_from_rfc3339(started) else {
|
|
340
|
+
return entry.elapsed_ms;
|
|
341
|
+
};
|
|
342
|
+
let running_ms = Utc::now()
|
|
343
|
+
.signed_duration_since(started_dt)
|
|
344
|
+
.num_milliseconds()
|
|
345
|
+
.max(0) as u64;
|
|
346
|
+
entry.elapsed_ms + running_ms
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
pub fn handle_stop(arguments: &Value) -> Result<String> {
|
|
350
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
351
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
352
|
+
let tasks_dir = handoff.join("tasks");
|
|
353
|
+
let config_path = handoff.join("config.toml");
|
|
354
|
+
let config = read_config(&config_path)?;
|
|
355
|
+
|
|
356
|
+
let task_id = arguments
|
|
357
|
+
.get("task_id")
|
|
358
|
+
.and_then(|v| v.as_str())
|
|
359
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
360
|
+
|
|
361
|
+
let task_dir = find_task_dir_by_id(&tasks_dir, task_id)?
|
|
362
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
363
|
+
|
|
364
|
+
let timer = ensure_timer_dir(&handoff)?;
|
|
365
|
+
let ttl = config.settings.timer_authority_ttl_secs;
|
|
366
|
+
|
|
367
|
+
let (provider, _auth) = determine_provider(&timer, &config.settings.timer_provider, ttl)?;
|
|
368
|
+
|
|
369
|
+
match provider {
|
|
370
|
+
TimerProvider::Vscode { authority_alive } => {
|
|
371
|
+
let req = TimerRequest {
|
|
372
|
+
version: 1,
|
|
373
|
+
id: generate_request_id(),
|
|
374
|
+
cmd: "stop".to_string(),
|
|
375
|
+
task_id: task_id.to_string(),
|
|
376
|
+
issued_by: "mcp".to_string(),
|
|
377
|
+
issued_at: Utc::now().to_rfc3339(),
|
|
378
|
+
};
|
|
379
|
+
write_request(&timer, &req)?;
|
|
380
|
+
let warning = if !authority_alive {
|
|
381
|
+
" WARNING: VSCode extension authority is stale or absent — the request may not be processed."
|
|
382
|
+
} else {
|
|
383
|
+
""
|
|
384
|
+
};
|
|
385
|
+
Ok(format!(
|
|
386
|
+
"Timer stop delegated to VSCode extension for task {task_id} (request {}).{warning}",
|
|
387
|
+
req.id
|
|
388
|
+
))
|
|
389
|
+
}
|
|
390
|
+
TimerProvider::McpFallback => {
|
|
391
|
+
update_mcp_heartbeat(&timer, ttl)?;
|
|
392
|
+
|
|
393
|
+
// Crash-safe ordering: remove timer entry FIRST (via optimistic lock),
|
|
394
|
+
// then add actual_hours. If crash occurs between, the timer is already
|
|
395
|
+
// gone (hours are lost rather than double-counted).
|
|
396
|
+
//
|
|
397
|
+
// Both the "entry exists + tracking?" check AND the removal happen
|
|
398
|
+
// inside the same optimistic-lock closure to prevent TOCTOU: two
|
|
399
|
+
// concurrent stops would both read the entry, compute hours, and
|
|
400
|
+
// double-count.
|
|
401
|
+
let task_id_owned = task_id.to_string();
|
|
402
|
+
let hours_cell = std::cell::Cell::new(0.0_f64);
|
|
403
|
+
read_modify_write_state(&timer, |s| {
|
|
404
|
+
let entry = s.timers.get(&task_id_owned).ok_or_else(|| {
|
|
405
|
+
anyhow::anyhow!(
|
|
406
|
+
"No active timer for task {task_id_owned}. Start one first with handoff_timer_start."
|
|
407
|
+
)
|
|
408
|
+
})?;
|
|
409
|
+
if entry.state != "tracking" {
|
|
410
|
+
anyhow::bail!(
|
|
411
|
+
"Timer for task {} is not running (state: {})",
|
|
412
|
+
task_id_owned,
|
|
413
|
+
entry.state
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
let total_ms = compute_live_elapsed_ms(entry);
|
|
417
|
+
hours_cell.set(total_ms as f64 / 3_600_000.0);
|
|
418
|
+
s.timers.remove(&task_id_owned);
|
|
419
|
+
Ok(())
|
|
420
|
+
})?;
|
|
421
|
+
|
|
422
|
+
let hours_to_add = hours_cell.get();
|
|
423
|
+
|
|
424
|
+
let new_actual = std::cell::Cell::new(0.0_f64);
|
|
425
|
+
let new_remaining: std::cell::Cell<Option<f64>> = std::cell::Cell::new(None);
|
|
426
|
+
|
|
427
|
+
read_modify_write_task(&task_dir, |data, status| {
|
|
428
|
+
let schedule = data.schedule.get_or_insert_with(Default::default);
|
|
429
|
+
let actual = schedule.actual_hours.unwrap_or(0.0) + hours_to_add;
|
|
430
|
+
schedule.actual_hours = Some(actual);
|
|
431
|
+
new_actual.set(actual);
|
|
432
|
+
|
|
433
|
+
if let Some(rem) = schedule.remaining_hours {
|
|
434
|
+
let r = (rem - hours_to_add).max(0.0);
|
|
435
|
+
schedule.remaining_hours = Some(r);
|
|
436
|
+
new_remaining.set(Some(r));
|
|
437
|
+
} else {
|
|
438
|
+
new_remaining.set(None);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
data.updated_at = Some(Utc::now().to_rfc3339());
|
|
442
|
+
Ok(status.to_string())
|
|
443
|
+
})?;
|
|
444
|
+
|
|
445
|
+
let remaining_msg = match new_remaining.get() {
|
|
446
|
+
Some(r) => format!(", remaining={r:.2}h"),
|
|
447
|
+
None => String::new(),
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
Ok(format!(
|
|
451
|
+
"MCP fallback timer stopped for task {task_id}: +{hours_to_add:.4}h logged, actual={:.2}h{remaining_msg}",
|
|
452
|
+
new_actual.get()
|
|
453
|
+
))
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
pub fn handle_get_time(arguments: &Value) -> Result<String> {
|
|
459
|
+
let project_dir = resolve_project_dir(arguments)?;
|
|
460
|
+
let handoff = ensure_handoff_exists(&project_dir)?;
|
|
461
|
+
let config_path = handoff.join("config.toml");
|
|
462
|
+
let config = read_config(&config_path)?;
|
|
463
|
+
|
|
464
|
+
let task_id = arguments
|
|
465
|
+
.get("task_id")
|
|
466
|
+
.and_then(|v| v.as_str())
|
|
467
|
+
.ok_or_else(|| anyhow::anyhow!("'task_id' parameter is required"))?;
|
|
468
|
+
|
|
469
|
+
let tasks_dir = handoff.join("tasks");
|
|
470
|
+
find_task_dir_by_id(&tasks_dir, task_id)?
|
|
471
|
+
.ok_or_else(|| anyhow::anyhow!("{}", suggest_task_id(&tasks_dir, task_id)))?;
|
|
472
|
+
|
|
473
|
+
if config.settings.timer_provider == "off" {
|
|
474
|
+
anyhow::bail!("Timer is disabled (timer_provider = off)");
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let timer = timer_dir(&handoff);
|
|
478
|
+
if !timer.exists() {
|
|
479
|
+
return Ok(serde_json::to_string_pretty(&serde_json::json!({
|
|
480
|
+
"task_id": task_id,
|
|
481
|
+
"state": "stopped",
|
|
482
|
+
"elapsed_ms": 0,
|
|
483
|
+
"elapsed_hours": 0.0,
|
|
484
|
+
"authority": null,
|
|
485
|
+
"message": "No timer has been started for this task"
|
|
486
|
+
}))?);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
let auth = read_authority(&timer)?;
|
|
490
|
+
let authority_owner = auth.as_ref().map(|a| a.owner.as_str());
|
|
491
|
+
let authority_alive = auth.as_ref().map(is_authority_alive).unwrap_or(false);
|
|
492
|
+
|
|
493
|
+
let state = read_state(&timer)?;
|
|
494
|
+
let entry = state.as_ref().and_then(|s| s.timers.get(task_id));
|
|
495
|
+
|
|
496
|
+
match entry {
|
|
497
|
+
Some(e) => {
|
|
498
|
+
let live_ms = compute_live_elapsed_ms(e);
|
|
499
|
+
let elapsed_hours = live_ms as f64 / 3_600_000.0;
|
|
500
|
+
let current_actual = get_base_hours(&tasks_dir, task_id).unwrap_or(e.base_hours);
|
|
501
|
+
let projected_total = current_actual + elapsed_hours;
|
|
502
|
+
Ok(serde_json::to_string_pretty(&serde_json::json!({
|
|
503
|
+
"task_id": task_id,
|
|
504
|
+
"state": e.state,
|
|
505
|
+
"elapsed_ms": live_ms,
|
|
506
|
+
"elapsed_hours": format!("{elapsed_hours:.4}"),
|
|
507
|
+
"base_hours": e.base_hours,
|
|
508
|
+
"current_actual_hours": current_actual,
|
|
509
|
+
"projected_total_hours": format!("{projected_total:.4}"),
|
|
510
|
+
"paused_by_idle": e.paused_by_idle,
|
|
511
|
+
"authority": {
|
|
512
|
+
"owner": authority_owner,
|
|
513
|
+
"alive": authority_alive
|
|
514
|
+
}
|
|
515
|
+
}))?)
|
|
516
|
+
}
|
|
517
|
+
None => Ok(serde_json::to_string_pretty(&serde_json::json!({
|
|
518
|
+
"task_id": task_id,
|
|
519
|
+
"state": "stopped",
|
|
520
|
+
"elapsed_ms": 0,
|
|
521
|
+
"elapsed_hours": 0.0,
|
|
522
|
+
"authority": {
|
|
523
|
+
"owner": authority_owner,
|
|
524
|
+
"alive": authority_alive
|
|
525
|
+
},
|
|
526
|
+
"message": "No active timer for this task"
|
|
527
|
+
}))?),
|
|
528
|
+
}
|
|
529
|
+
}
|