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.
@@ -1,5 +1,6 @@
1
1
  use anyhow::{Context, Result};
2
2
  use serde_json::Value;
3
+ use toml_edit::{DocumentMut, Item, Value as TomlValue};
3
4
 
4
5
  use super::resolve_project_dir;
5
6
  use crate::storage::config::{read_config, write_config};
@@ -9,9 +10,19 @@ pub fn handle_get(arguments: &Value) -> Result<String> {
9
10
  let project_dir = resolve_project_dir(arguments)?;
10
11
 
11
12
  let handoff = ensure_handoff_exists(&project_dir)?;
12
- let config = read_config(&handoff.join("config.toml"))?;
13
- let result = serde_json::to_value(&config).context("Failed to serialize config")?;
14
- serde_json::to_string_pretty(&result).context("Failed to format config")
13
+ let config_path = handoff.join("config.toml");
14
+
15
+ let raw = std::fs::read_to_string(&config_path)
16
+ .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
17
+ let doc: DocumentMut = raw.parse().with_context(|| "Failed to parse config.toml")?;
18
+
19
+ let toml_str = doc.to_string();
20
+ let toml_value: toml::Value =
21
+ toml::from_str(&toml_str).with_context(|| "Failed to deserialize config")?;
22
+ let json_value =
23
+ serde_json::to_value(&toml_value).with_context(|| "Failed to convert to JSON")?;
24
+
25
+ serde_json::to_string_pretty(&json_value).context("Failed to format config")
15
26
  }
16
27
 
17
28
  pub fn handle_update(arguments: &Value) -> Result<String> {
@@ -19,7 +30,6 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
19
30
 
20
31
  let handoff = ensure_handoff_exists(&project_dir)?;
21
32
  let config_path = handoff.join("config.toml");
22
- let mut config = read_config(&config_path)?;
23
33
 
24
34
  let updates = arguments
25
35
  .get("updates")
@@ -29,83 +39,153 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
29
39
  .as_object()
30
40
  .ok_or_else(|| anyhow::anyhow!("'updates' must be an object"))?;
31
41
 
42
+ // Separate typed keys (settings/dashboard/project) from raw TOML keys
43
+ let typed_keys = [
44
+ "settings.history_limit",
45
+ "settings.done_task_limit",
46
+ "settings.auto_git_summary",
47
+ "settings.require_estimate_hours",
48
+ "settings.ai_estimate_multiplier",
49
+ "settings.context_files",
50
+ "dashboard.scan_dirs",
51
+ "dashboard.exclude_patterns",
52
+ "project.name",
53
+ "project.description",
54
+ ];
55
+
56
+ let mut has_typed = false;
57
+ let mut has_raw = false;
58
+
59
+ for key in updates.keys() {
60
+ if typed_keys.contains(&key.as_str()) {
61
+ has_typed = true;
62
+ } else {
63
+ has_raw = true;
64
+ }
65
+ }
66
+
32
67
  let mut applied = Vec::new();
33
68
 
34
- for (key, value) in updates {
35
- match key.as_str() {
36
- "settings.history_limit" => {
37
- if let Some(n) = value.as_u64() {
38
- config.settings.history_limit = n as u32;
39
- applied.push(format!("settings.history_limit = {n}"));
69
+ // Handle typed keys via existing Config struct
70
+ if has_typed {
71
+ let mut config = read_config(&config_path)?;
72
+ for (key, value) in updates {
73
+ match key.as_str() {
74
+ "settings.history_limit" => {
75
+ if let Some(n) = value.as_u64() {
76
+ config.settings.history_limit = n as u32;
77
+ applied.push(format!("settings.history_limit = {n}"));
78
+ }
40
79
  }
41
- }
42
- "settings.done_task_limit" => {
43
- if let Some(n) = value.as_u64() {
44
- config.settings.done_task_limit = n as u32;
45
- applied.push(format!("settings.done_task_limit = {n}"));
80
+ "settings.done_task_limit" => {
81
+ if let Some(n) = value.as_u64() {
82
+ config.settings.done_task_limit = n as u32;
83
+ applied.push(format!("settings.done_task_limit = {n}"));
84
+ }
46
85
  }
47
- }
48
- "settings.auto_git_summary" => {
49
- if let Some(b) = value.as_bool() {
50
- config.settings.auto_git_summary = b;
51
- applied.push(format!("settings.auto_git_summary = {b}"));
86
+ "settings.auto_git_summary" => {
87
+ if let Some(b) = value.as_bool() {
88
+ config.settings.auto_git_summary = b;
89
+ applied.push(format!("settings.auto_git_summary = {b}"));
90
+ }
52
91
  }
53
- }
54
- "settings.context_files" => {
55
- if let Some(arr) = value.as_array() {
56
- config.settings.context_files = arr
57
- .iter()
58
- .filter_map(|v| v.as_str().map(String::from))
59
- .collect();
60
- applied.push(format!(
61
- "settings.context_files = {:?}",
62
- config.settings.context_files
63
- ));
92
+ "settings.require_estimate_hours" => {
93
+ if let Some(b) = value.as_bool() {
94
+ config.settings.require_estimate_hours = b;
95
+ applied.push(format!("settings.require_estimate_hours = {b}"));
96
+ }
64
97
  }
65
- }
66
- "dashboard.scan_dirs" => {
67
- if let Some(arr) = value.as_array() {
68
- config.dashboard.scan_dirs = arr
69
- .iter()
70
- .filter_map(|v| v.as_str().map(String::from))
71
- .collect();
72
- applied.push(format!(
73
- "dashboard.scan_dirs = {:?}",
74
- config.dashboard.scan_dirs
75
- ));
98
+ "settings.ai_estimate_multiplier" => {
99
+ if let Some(n) = value.as_f64() {
100
+ if n < 0.0 {
101
+ anyhow::bail!("settings.ai_estimate_multiplier must be >= 0");
102
+ }
103
+ config.settings.ai_estimate_multiplier = n;
104
+ applied.push(format!("settings.ai_estimate_multiplier = {n}"));
105
+ }
76
106
  }
77
- }
78
- "dashboard.exclude_patterns" => {
79
- if let Some(arr) = value.as_array() {
80
- config.dashboard.exclude_patterns = arr
81
- .iter()
82
- .filter_map(|v| v.as_str().map(String::from))
83
- .collect();
84
- applied.push(format!(
85
- "dashboard.exclude_patterns = {:?}",
86
- config.dashboard.exclude_patterns
87
- ));
107
+ "settings.context_files" => {
108
+ if let Some(arr) = value.as_array() {
109
+ config.settings.context_files = arr
110
+ .iter()
111
+ .filter_map(|v| v.as_str().map(String::from))
112
+ .collect();
113
+ applied.push(format!(
114
+ "settings.context_files = {:?}",
115
+ config.settings.context_files
116
+ ));
117
+ }
88
118
  }
89
- }
90
- "project.name" => {
91
- if let Some(s) = value.as_str() {
92
- config.project.name = s.to_string();
93
- applied.push(format!("project.name = {s}"));
119
+ "dashboard.scan_dirs" => {
120
+ if let Some(arr) = value.as_array() {
121
+ config.dashboard.scan_dirs = arr
122
+ .iter()
123
+ .filter_map(|v| v.as_str().map(String::from))
124
+ .collect();
125
+ applied.push(format!(
126
+ "dashboard.scan_dirs = {:?}",
127
+ config.dashboard.scan_dirs
128
+ ));
129
+ }
94
130
  }
95
- }
96
- "project.description" => {
97
- if let Some(s) = value.as_str() {
98
- config.project.description = Some(s.to_string());
99
- applied.push(format!("project.description = {s}"));
131
+ "dashboard.exclude_patterns" => {
132
+ if let Some(arr) = value.as_array() {
133
+ config.dashboard.exclude_patterns = arr
134
+ .iter()
135
+ .filter_map(|v| v.as_str().map(String::from))
136
+ .collect();
137
+ applied.push(format!(
138
+ "dashboard.exclude_patterns = {:?}",
139
+ config.dashboard.exclude_patterns
140
+ ));
141
+ }
100
142
  }
101
- }
102
- other => {
103
- applied.push(format!("{other}: unknown key (skipped)"));
143
+ "project.name" => {
144
+ if let Some(s) = value.as_str() {
145
+ config.project.name = s.to_string();
146
+ applied.push(format!("project.name = {s}"));
147
+ }
148
+ }
149
+ "project.description" => {
150
+ if let Some(s) = value.as_str() {
151
+ config.project.description = Some(s.to_string());
152
+ applied.push(format!("project.description = {s}"));
153
+ }
154
+ }
155
+ _ => {}
104
156
  }
105
157
  }
158
+ write_config(&config_path, &config)?;
106
159
  }
107
160
 
108
- write_config(&config_path, &config)?;
161
+ // Handle raw TOML keys (calendar, assignees, effort_budget, gantt_view, etc.)
162
+ if has_raw {
163
+ let raw = std::fs::read_to_string(&config_path)
164
+ .with_context(|| format!("Failed to read config: {}", config_path.display()))?;
165
+ let mut doc: DocumentMut = raw
166
+ .parse()
167
+ .with_context(|| "Failed to parse config.toml for raw update")?;
168
+
169
+ for (key, value) in updates {
170
+ if typed_keys.contains(&key.as_str()) {
171
+ continue;
172
+ }
173
+
174
+ let parts: Vec<&str> = key.split('.').collect();
175
+ if parts.is_empty() {
176
+ applied.push(format!("{key}: invalid key"));
177
+ continue;
178
+ }
179
+
180
+ match set_toml_value(&mut doc, &parts, value) {
181
+ Ok(()) => applied.push(format!("{key} = {value}")),
182
+ Err(e) => applied.push(format!("{key}: error ({e})")),
183
+ }
184
+ }
185
+
186
+ crate::storage::atomic_write(&config_path, doc.to_string().as_bytes())
187
+ .with_context(|| format!("Failed to write config: {}", config_path.display()))?;
188
+ }
109
189
 
110
190
  if applied.is_empty() {
111
191
  Ok("No updates applied".to_string())
@@ -113,3 +193,85 @@ pub fn handle_update(arguments: &Value) -> Result<String> {
113
193
  Ok(format!("Updated config:\n{}", applied.join("\n")))
114
194
  }
115
195
  }
196
+
197
+ fn set_toml_value(doc: &mut DocumentMut, parts: &[&str], json_val: &Value) -> Result<()> {
198
+ let toml_val = json_to_toml_value(json_val)?;
199
+
200
+ match parts.len() {
201
+ 1 => {
202
+ doc[parts[0]] = Item::Value(toml_val);
203
+ }
204
+ 2 => {
205
+ if !doc.contains_table(parts[0]) {
206
+ doc[parts[0]] = Item::Table(toml_edit::Table::new());
207
+ }
208
+ doc[parts[0]][parts[1]] = Item::Value(toml_val);
209
+ }
210
+ 3 => {
211
+ if !doc.contains_table(parts[0]) {
212
+ doc[parts[0]] = Item::Table(toml_edit::Table::new());
213
+ }
214
+ let table = doc[parts[0]]
215
+ .as_table_mut()
216
+ .ok_or_else(|| anyhow::anyhow!("{} is not a table", parts[0]))?;
217
+ if !table.contains_table(parts[1]) {
218
+ table[parts[1]] = Item::Table(toml_edit::Table::new());
219
+ }
220
+ table[parts[1]][parts[2]] = Item::Value(toml_val);
221
+ }
222
+ 4 => {
223
+ if !doc.contains_table(parts[0]) {
224
+ doc[parts[0]] = Item::Table(toml_edit::Table::new());
225
+ }
226
+ let t0 = doc[parts[0]]
227
+ .as_table_mut()
228
+ .ok_or_else(|| anyhow::anyhow!("{} is not a table", parts[0]))?;
229
+ if !t0.contains_table(parts[1]) {
230
+ t0[parts[1]] = Item::Table(toml_edit::Table::new());
231
+ }
232
+ let t1 = t0[parts[1]]
233
+ .as_table_mut()
234
+ .ok_or_else(|| anyhow::anyhow!("{}.{} is not a table", parts[0], parts[1]))?;
235
+ if !t1.contains_table(parts[2]) {
236
+ t1[parts[2]] = Item::Table(toml_edit::Table::new());
237
+ }
238
+ t1[parts[2]][parts[3]] = Item::Value(toml_val);
239
+ }
240
+ _ => {
241
+ anyhow::bail!("Key depth > 4 not supported");
242
+ }
243
+ }
244
+
245
+ Ok(())
246
+ }
247
+
248
+ fn json_to_toml_value(val: &Value) -> Result<TomlValue> {
249
+ match val {
250
+ Value::String(s) => Ok(TomlValue::from(s.as_str())),
251
+ Value::Number(n) => {
252
+ if let Some(i) = n.as_i64() {
253
+ Ok(TomlValue::from(i))
254
+ } else if let Some(f) = n.as_f64() {
255
+ Ok(TomlValue::from(f))
256
+ } else {
257
+ anyhow::bail!("Unsupported number: {n}")
258
+ }
259
+ }
260
+ Value::Bool(b) => Ok(TomlValue::from(*b)),
261
+ Value::Array(arr) => {
262
+ let mut toml_arr = toml_edit::Array::new();
263
+ for item in arr {
264
+ toml_arr.push(json_to_toml_value(item)?);
265
+ }
266
+ Ok(TomlValue::Array(toml_arr))
267
+ }
268
+ Value::Object(obj) => {
269
+ let mut inline = toml_edit::InlineTable::new();
270
+ for (k, v) in obj {
271
+ inline.insert(k, json_to_toml_value(v)?);
272
+ }
273
+ Ok(TomlValue::InlineTable(inline))
274
+ }
275
+ Value::Null => Ok(TomlValue::from("")),
276
+ }
277
+ }
@@ -0,0 +1,183 @@
1
+ //! Shared helpers for config.toml CRUD handlers (assignees, milestones,
2
+ //! calendar, labels, project start). All mutate the raw TOML document via
3
+ //! `toml_edit` so that comments and unrelated keys are preserved, then write it
4
+ //! back atomically.
5
+
6
+ use std::path::{Path, PathBuf};
7
+
8
+ use anyhow::{Context, Result};
9
+ use serde_json::Value;
10
+ use toml_edit::{Array, DocumentMut, Item, Value as TomlValue};
11
+
12
+ use super::resolve_project_dir;
13
+ use crate::storage::ensure_handoff_exists;
14
+
15
+ /// Resolve the project dir, ensure `.handoff/` exists, and return the
16
+ /// config.toml path.
17
+ pub fn config_path(arguments: &Value) -> Result<PathBuf> {
18
+ let project_dir = resolve_project_dir(arguments)?;
19
+ let handoff = ensure_handoff_exists(&project_dir)?;
20
+ Ok(handoff.join("config.toml"))
21
+ }
22
+
23
+ /// Parse config.toml into a mutable document.
24
+ pub fn load_doc(path: &Path) -> Result<DocumentMut> {
25
+ let raw = std::fs::read_to_string(path)
26
+ .with_context(|| format!("Failed to read config: {}", path.display()))?;
27
+ raw.parse::<DocumentMut>()
28
+ .with_context(|| format!("Failed to parse config: {}", path.display()))
29
+ }
30
+
31
+ /// Serialize and atomically write the document back to disk.
32
+ pub fn save_doc(path: &Path, doc: &DocumentMut) -> Result<()> {
33
+ crate::storage::atomic_write(path, doc.to_string().as_bytes())
34
+ .with_context(|| format!("Failed to write config: {}", path.display()))
35
+ }
36
+
37
+ /// Required string argument or a descriptive error.
38
+ pub fn require_str<'a>(arguments: &'a Value, key: &str) -> Result<&'a str> {
39
+ arguments
40
+ .get(key)
41
+ .and_then(|v| v.as_str())
42
+ .ok_or_else(|| anyhow::anyhow!("'{key}' is required"))
43
+ }
44
+
45
+ /// Set `table[field]` to a string if the JSON arg is present; if it is JSON
46
+ /// null, remove the key (allows clearing a value).
47
+ pub fn set_opt_str(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
48
+ match arg {
49
+ Some(Value::Null) => {
50
+ table.remove(field);
51
+ }
52
+ Some(v) => {
53
+ if let Some(s) = v.as_str() {
54
+ table[field] = Item::Value(TomlValue::from(s));
55
+ }
56
+ }
57
+ None => {}
58
+ }
59
+ }
60
+
61
+ /// Set `table[field]` to a number if present; null removes it.
62
+ pub fn set_opt_f64(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
63
+ match arg {
64
+ Some(Value::Null) => {
65
+ table.remove(field);
66
+ }
67
+ Some(v) => {
68
+ if let Some(n) = v.as_f64() {
69
+ table[field] = Item::Value(TomlValue::from(n));
70
+ }
71
+ }
72
+ None => {}
73
+ }
74
+ }
75
+
76
+ /// Set `table[field]` to a bool if present; null removes it.
77
+ pub fn set_opt_bool(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
78
+ match arg {
79
+ Some(Value::Null) => {
80
+ table.remove(field);
81
+ }
82
+ Some(v) => {
83
+ if let Some(b) = v.as_bool() {
84
+ table[field] = Item::Value(TomlValue::from(b));
85
+ }
86
+ }
87
+ None => {}
88
+ }
89
+ }
90
+
91
+ /// Set `table[field]` to a TOML array of strings if the JSON arg is an array;
92
+ /// null removes it.
93
+ pub fn set_string_array(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
94
+ match arg {
95
+ Some(Value::Null) => {
96
+ table.remove(field);
97
+ }
98
+ Some(Value::Array(items)) => {
99
+ let mut arr = Array::new();
100
+ for it in items {
101
+ if let Some(s) = it.as_str() {
102
+ arr.push(s);
103
+ }
104
+ }
105
+ table[field] = Item::Value(TomlValue::Array(arr));
106
+ }
107
+ _ => {}
108
+ }
109
+ }
110
+
111
+ /// Set `table[field]` to a TOML array preserving integer-or-string items
112
+ /// (used for `closed_weekdays`, which may be numbers or weekday names); null removes it.
113
+ pub fn set_mixed_array(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
114
+ match arg {
115
+ Some(Value::Null) => {
116
+ table.remove(field);
117
+ }
118
+ Some(Value::Array(items)) => {
119
+ let mut arr = Array::new();
120
+ for it in items {
121
+ if let Some(i) = it.as_i64() {
122
+ arr.push(i);
123
+ } else if let Some(s) = it.as_str() {
124
+ arr.push(s);
125
+ }
126
+ }
127
+ table[field] = Item::Value(TomlValue::Array(arr));
128
+ }
129
+ _ => {}
130
+ }
131
+ }
132
+
133
+ /// Replace a nested `[parent.<key>.<field>]`-style map of `{name: hours}` with
134
+ /// the provided JSON object (e.g. `day_hours`). null removes the whole sub-table.
135
+ pub fn set_f64_map(table: &mut toml_edit::Table, field: &str, arg: Option<&Value>) {
136
+ match arg {
137
+ Some(Value::Null) => {
138
+ table.remove(field);
139
+ }
140
+ Some(Value::Object(map)) => {
141
+ let mut sub = toml_edit::Table::new();
142
+ sub.set_implicit(false);
143
+ for (k, v) in map {
144
+ if let Some(n) = v.as_f64() {
145
+ sub[k] = Item::Value(TomlValue::from(n));
146
+ }
147
+ }
148
+ table[field] = Item::Table(sub);
149
+ }
150
+ _ => {}
151
+ }
152
+ }
153
+
154
+ /// Get a mutable reference to `doc[section]` as a table, creating it if absent.
155
+ pub fn ensure_table<'a>(
156
+ doc: &'a mut DocumentMut,
157
+ section: &str,
158
+ ) -> Result<&'a mut toml_edit::Table> {
159
+ if !doc.contains_table(section) {
160
+ doc[section] = Item::Table(toml_edit::Table::new());
161
+ }
162
+ doc[section]
163
+ .as_table_mut()
164
+ .ok_or_else(|| anyhow::anyhow!("[{section}] exists but is not a table"))
165
+ }
166
+
167
+ /// Get a mutable reference to `doc[section][key]` as a table, creating both if
168
+ /// absent. Marks the parent as a dotted/implicit table so it serializes as
169
+ /// `[section.key]`.
170
+ pub fn ensure_subtable<'a>(
171
+ doc: &'a mut DocumentMut,
172
+ section: &str,
173
+ key: &str,
174
+ ) -> Result<&'a mut toml_edit::Table> {
175
+ let parent = ensure_table(doc, section)?;
176
+ parent.set_implicit(true);
177
+ if !parent.contains_table(key) {
178
+ parent[key] = Item::Table(toml_edit::Table::new());
179
+ }
180
+ parent[key]
181
+ .as_table_mut()
182
+ .ok_or_else(|| anyhow::anyhow!("[{section}.{key}] exists but is not a table"))
183
+ }
@@ -0,0 +1,48 @@
1
+ use anyhow::{Context, Result};
2
+ use serde_json::Value;
3
+
4
+ use super::resolve_project_dir;
5
+ use crate::storage::ensure_handoff_exists;
6
+
7
+ pub fn handle(arguments: &Value) -> Result<String> {
8
+ let project_dir = resolve_project_dir(arguments)?;
9
+ let handoff = ensure_handoff_exists(&project_dir)?;
10
+ let sessions_dir = handoff.join("sessions");
11
+
12
+ let session_id = arguments
13
+ .get("session_id")
14
+ .and_then(|v| v.as_str())
15
+ .ok_or_else(|| anyhow::anyhow!("'session_id' parameter is required"))?;
16
+
17
+ if !sessions_dir.exists() {
18
+ anyhow::bail!("Sessions directory not found");
19
+ }
20
+
21
+ for entry in std::fs::read_dir(&sessions_dir)
22
+ .with_context(|| format!("Failed to read sessions dir: {}", sessions_dir.display()))?
23
+ {
24
+ let entry = entry?;
25
+ let name = entry.file_name().to_string_lossy().to_string();
26
+ if !name.ends_with(".json") {
27
+ continue;
28
+ }
29
+
30
+ let content = match std::fs::read_to_string(entry.path()) {
31
+ Ok(c) => c,
32
+ Err(_) => continue,
33
+ };
34
+ let data: Value = match serde_json::from_str(&content) {
35
+ Ok(d) => d,
36
+ Err(_) => continue,
37
+ };
38
+
39
+ let file_id = data.get("id").and_then(|v| v.as_str()).unwrap_or("");
40
+ if file_id == session_id || name.contains(session_id) {
41
+ return serde_json::to_string_pretty(&data).map_err(Into::into);
42
+ }
43
+ }
44
+
45
+ anyhow::bail!(
46
+ "Session not found: {session_id}. Use handoff_list_sessions to see available session IDs."
47
+ )
48
+ }
@@ -40,6 +40,7 @@ pub fn handle(arguments: &Value) -> Result<String> {
40
40
  "schedule": data.schedule,
41
41
  "dependencies": data.dependencies,
42
42
  "order": data.order,
43
+ "assignee": data.assignee,
43
44
  });
44
45
 
45
46
  serde_json::to_string_pretty(&result).map_err(Into::into)
@@ -250,6 +250,11 @@ fn create_task_recursive(
250
250
  .get("order")
251
251
  .and_then(|v| v.as_u64())
252
252
  .map(|v| v as u32),
253
+ assignee: task_val
254
+ .get("assignee")
255
+ .and_then(|v| v.as_str())
256
+ .map(String::from),
257
+ extra: std::collections::HashMap::new(),
253
258
  };
254
259
 
255
260
  write_task(&task_dir, status, &data)?;
@@ -340,9 +345,11 @@ fn extract_schedule(val: &Value) -> Option<Schedule> {
340
345
  .map(String::from),
341
346
  estimate_hours: sched.get("estimate_hours").and_then(|v| v.as_f64()),
342
347
  actual_hours: sched.get("actual_hours").and_then(|v| v.as_f64()),
348
+ remaining_hours: sched.get("remaining_hours").and_then(|v| v.as_f64()),
343
349
  milestone: sched
344
350
  .get("milestone")
345
351
  .and_then(|v| v.as_str())
346
352
  .map(String::from),
353
+ pinned: sched.get("pinned").and_then(|v| v.as_bool()),
347
354
  })
348
355
  }