handoff-mcp-server 0.26.0 → 0.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -4
- package/bin/handoff-mcp.js +56 -13
- package/bin/resolve-binary.js +122 -0
- package/package.json +14 -9
- package/Cargo.lock +0 -686
- package/Cargo.toml +0 -30
- package/scripts/cargo-env.sh +0 -29
- package/scripts/handoff-memory-hook.py +0 -208
- package/scripts/install-local.sh +0 -109
- package/scripts/postinstall.js +0 -50
- package/scripts/sync-plugin-skills.sh +0 -35
- package/scripts/sync-plugin-version.sh +0 -85
- package/scripts/sync-workflow-inline.sh +0 -138
- package/src/cli.rs +0 -551
- package/src/context/injection.rs +0 -276
- package/src/context/mod.rs +0 -129
- package/src/lib.rs +0 -5
- package/src/main.rs +0 -157
- package/src/mcp/handlers/assignees.rs +0 -254
- package/src/mcp/handlers/auto_schedule.rs +0 -489
- package/src/mcp/handlers/bulk_update.rs +0 -155
- package/src/mcp/handlers/calendar.rs +0 -196
- package/src/mcp/handlers/capacity.rs +0 -318
- package/src/mcp/handlers/check_criterion.rs +0 -70
- package/src/mcp/handlers/config.rs +0 -402
- package/src/mcp/handlers/config_crud.rs +0 -183
- package/src/mcp/handlers/dashboard.rs +0 -214
- package/src/mcp/handlers/docs.rs +0 -2288
- package/src/mcp/handlers/docs_query.rs +0 -1335
- package/src/mcp/handlers/fork_session.rs +0 -91
- package/src/mcp/handlers/get_session.rs +0 -48
- package/src/mcp/handlers/get_task.rs +0 -53
- package/src/mcp/handlers/import_context.rs +0 -470
- package/src/mcp/handlers/init.rs +0 -28
- package/src/mcp/handlers/list_sessions.rs +0 -187
- package/src/mcp/handlers/list_tasks.rs +0 -308
- package/src/mcp/handlers/load_context.rs +0 -361
- package/src/mcp/handlers/log_time.rs +0 -67
- package/src/mcp/handlers/memory.rs +0 -961
- package/src/mcp/handlers/merge_sessions.rs +0 -103
- package/src/mcp/handlers/metrics.rs +0 -196
- package/src/mcp/handlers/milestones.rs +0 -102
- package/src/mcp/handlers/mod.rs +0 -140
- package/src/mcp/handlers/refer.rs +0 -307
- package/src/mcp/handlers/referrals.rs +0 -74
- package/src/mcp/handlers/save_context.rs +0 -354
- package/src/mcp/handlers/task_checklist.rs +0 -507
- package/src/mcp/handlers/timer.rs +0 -529
- package/src/mcp/handlers/update_session.rs +0 -197
- package/src/mcp/handlers/update_task.rs +0 -452
- package/src/mcp/mod.rs +0 -6
- package/src/mcp/protocol.rs +0 -41
- package/src/mcp/resources.rs +0 -57
- package/src/mcp/router.rs +0 -154
- package/src/mcp/tools.rs +0 -1522
- package/src/mcp/types.rs +0 -108
- package/src/setup.rs +0 -1212
- package/src/storage/config.rs +0 -578
- package/src/storage/docs/frontmatter.rs +0 -509
- package/src/storage/docs/mod.rs +0 -835
- package/src/storage/docs/model.rs +0 -708
- package/src/storage/docs/reassemble.rs +0 -167
- package/src/storage/docs/split.rs +0 -377
- package/src/storage/git.rs +0 -47
- package/src/storage/memory/injected.rs +0 -340
- package/src/storage/memory/mod.rs +0 -236
- package/src/storage/memory/model.rs +0 -127
- package/src/storage/mod.rs +0 -96
- package/src/storage/referrals.rs +0 -248
- package/src/storage/sessions.rs +0 -859
- package/src/storage/tasks.rs +0 -957
- package/templates/claude-md-section.md +0 -12
|
@@ -1,529 +0,0 @@
|
|
|
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
|
-
}
|