handoff-mcp-server 0.26.0 → 0.27.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.
Files changed (72) hide show
  1. package/README.md +31 -4
  2. package/bin/handoff-mcp.js +56 -13
  3. package/bin/resolve-binary.js +122 -0
  4. package/package.json +14 -9
  5. package/Cargo.lock +0 -686
  6. package/Cargo.toml +0 -30
  7. package/scripts/cargo-env.sh +0 -29
  8. package/scripts/handoff-memory-hook.py +0 -208
  9. package/scripts/install-local.sh +0 -109
  10. package/scripts/postinstall.js +0 -50
  11. package/scripts/sync-plugin-skills.sh +0 -35
  12. package/scripts/sync-plugin-version.sh +0 -85
  13. package/scripts/sync-workflow-inline.sh +0 -138
  14. package/src/cli.rs +0 -551
  15. package/src/context/injection.rs +0 -276
  16. package/src/context/mod.rs +0 -129
  17. package/src/lib.rs +0 -5
  18. package/src/main.rs +0 -157
  19. package/src/mcp/handlers/assignees.rs +0 -254
  20. package/src/mcp/handlers/auto_schedule.rs +0 -489
  21. package/src/mcp/handlers/bulk_update.rs +0 -155
  22. package/src/mcp/handlers/calendar.rs +0 -196
  23. package/src/mcp/handlers/capacity.rs +0 -318
  24. package/src/mcp/handlers/check_criterion.rs +0 -70
  25. package/src/mcp/handlers/config.rs +0 -402
  26. package/src/mcp/handlers/config_crud.rs +0 -183
  27. package/src/mcp/handlers/dashboard.rs +0 -214
  28. package/src/mcp/handlers/docs.rs +0 -2288
  29. package/src/mcp/handlers/docs_query.rs +0 -1335
  30. package/src/mcp/handlers/fork_session.rs +0 -91
  31. package/src/mcp/handlers/get_session.rs +0 -48
  32. package/src/mcp/handlers/get_task.rs +0 -53
  33. package/src/mcp/handlers/import_context.rs +0 -470
  34. package/src/mcp/handlers/init.rs +0 -28
  35. package/src/mcp/handlers/list_sessions.rs +0 -187
  36. package/src/mcp/handlers/list_tasks.rs +0 -308
  37. package/src/mcp/handlers/load_context.rs +0 -361
  38. package/src/mcp/handlers/log_time.rs +0 -67
  39. package/src/mcp/handlers/memory.rs +0 -961
  40. package/src/mcp/handlers/merge_sessions.rs +0 -103
  41. package/src/mcp/handlers/metrics.rs +0 -196
  42. package/src/mcp/handlers/milestones.rs +0 -102
  43. package/src/mcp/handlers/mod.rs +0 -140
  44. package/src/mcp/handlers/refer.rs +0 -307
  45. package/src/mcp/handlers/referrals.rs +0 -74
  46. package/src/mcp/handlers/save_context.rs +0 -354
  47. package/src/mcp/handlers/task_checklist.rs +0 -507
  48. package/src/mcp/handlers/timer.rs +0 -529
  49. package/src/mcp/handlers/update_session.rs +0 -197
  50. package/src/mcp/handlers/update_task.rs +0 -452
  51. package/src/mcp/mod.rs +0 -6
  52. package/src/mcp/protocol.rs +0 -41
  53. package/src/mcp/resources.rs +0 -57
  54. package/src/mcp/router.rs +0 -154
  55. package/src/mcp/tools.rs +0 -1522
  56. package/src/mcp/types.rs +0 -108
  57. package/src/setup.rs +0 -1212
  58. package/src/storage/config.rs +0 -578
  59. package/src/storage/docs/frontmatter.rs +0 -509
  60. package/src/storage/docs/mod.rs +0 -835
  61. package/src/storage/docs/model.rs +0 -708
  62. package/src/storage/docs/reassemble.rs +0 -167
  63. package/src/storage/docs/split.rs +0 -377
  64. package/src/storage/git.rs +0 -47
  65. package/src/storage/memory/injected.rs +0 -340
  66. package/src/storage/memory/mod.rs +0 -236
  67. package/src/storage/memory/model.rs +0 -127
  68. package/src/storage/mod.rs +0 -96
  69. package/src/storage/referrals.rs +0 -248
  70. package/src/storage/sessions.rs +0 -859
  71. package/src/storage/tasks.rs +0 -957
  72. 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
- }