Skip to main content

sac/tools/
workset.rs

1use serde_json::Value;
2
3use crate::store::{self, WorksetDefinition, WorksetItemDefinition};
4use crate::tools::{require_str, ToolResult, ToolRuntime};
5use crate::types::ToolDefinition;
6
7pub fn define_definition() -> ToolDefinition {
8    use serde_json::json;
9    def(
10        "workset_define",
11        "Create or replace a durable high-level plan workset for this session.",
12        json!({
13            "type": "object",
14            "properties": {
15                "id": {
16                    "type": "string",
17                    "description": "Short stable handle for this workset. This is what the user passes to /run <workset>, so prefer lowercase words separated by hyphens."
18                },
19                "goal": {
20                    "type": "string",
21                    "description": "Durable user-facing objective for the whole plan. Capture what should be true when the workset is complete, not the orchestrator's current focus."
22                },
23                "status": {
24                    "type": "string",
25                    "description": "Whole-plan state, such as planned, running, blocked, completed, or abandoned."
26                },
27                "summary": {
28                    "type": "string",
29                    "description": "Compact synopsis of the plan and its current state. Keep it short enough to scan in the worksets pane."
30                },
31                "verification_recipe": {
32                    "type": "string",
33                    "description": "Optional end-to-end validation recipe for the workset, such as tests or manual checks that prove the goal was met."
34                },
35                "items": {
36                    "type": "array",
37                    "description": "Ordered high-level plan items. Order should reflect dependencies and the natural execution sequence.",
38                    "items": {
39                        "type": "object",
40                        "properties": {
41                            "title": {
42                                "type": "string",
43                                "description": "Concise label for this item. Make it stable enough to reference from depends_on."
44                            },
45                            "scope": {
46                                "type": "string",
47                                "description": "Owned files, modules, product area, or system boundary for this item. Use this to prevent overlapping implementation ownership."
48                            },
49                            "description": {
50                                "type": "string",
51                                "description": "Concrete work to do for this item, including important constraints or context."
52                            },
53                            "role": {
54                                "type": "string",
55                                "description": "Intended mode for the item, such as research, implementation, verification, cleanup, or coordination."
56                            },
57                            "depends_on": {
58                                "type": "array",
59                                "description": "Prerequisite workset item titles or ids that should be satisfied before this item starts. Use an empty array when there are none.",
60                                "items": { "type": "string" }
61                            },
62                            "acceptance": {
63                                "type": "string",
64                                "description": "Concrete condition that makes this item complete. Prefer observable outcomes over vague intent."
65                            },
66                            "notes": {
67                                "type": "string",
68                                "description": "Optional durable context, risks, discoveries, or execution notes for this item."
69                            },
70                            "status": {
71                                "type": "string",
72                                "description": "Per-item status, such as planned, in_progress, blocked, completed, or skipped. Defaults to planned when omitted."
73                            }
74                        },
75                        "required": ["title", "scope", "description", "role", "depends_on", "acceptance"]
76                    }
77                }
78            },
79            "required": ["id", "goal", "status", "summary", "items"]
80        }),
81    )
82}
83
84pub fn update_item_definition() -> ToolDefinition {
85    use serde_json::json;
86    def(
87        "workset_update_item",
88        "Update a single workset item's status and notes without replacing the entire workset. Use this to track execution progress as you complete work.",
89        json!({
90            "type": "object",
91            "properties": {
92                "id": {
93                    "type": "string",
94                    "description": "Workset id"
95                },
96                "title": {
97                    "type": "string",
98                    "description": "Title of the item to update (must match exactly)"
99                },
100                "status": {
101                    "type": "string",
102                    "description": "New status: planned, running, blocked, done"
103                },
104                "notes": {
105                    "type": "string",
106                    "description": "Key findings, results, or context to record"
107                }
108            },
109            "required": ["id", "title", "status"]
110        }),
111    )
112}
113
114pub fn read_definition() -> ToolDefinition {
115    use serde_json::json;
116    def(
117        "workset_read",
118        "Read the full structured definition of one workset in the current session.",
119        json!({
120            "type": "object",
121            "properties": {
122                "id": { "type": "string", "description": "Workset id." }
123            },
124            "required": ["id"]
125        }),
126    )
127}
128
129pub fn list_definition() -> ToolDefinition {
130    use serde_json::json;
131    def(
132        "workset_list",
133        "List persisted worksets in the current session.",
134        json!({
135            "type": "object",
136            "properties": {}
137        }),
138    )
139}
140
141pub async fn execute_define(args: Value, runtime: &ToolRuntime) -> ToolResult {
142    let session_id = match require_session(runtime) {
143        Ok(session_id) => session_id.to_string(),
144        Err(error) => return error,
145    };
146    let id = match require_str(&args, "id") {
147        Ok(id) => id,
148        Err(error) => return error,
149    };
150    let goal = match require_str(&args, "goal") {
151        Ok(goal) => goal,
152        Err(error) => return error,
153    };
154    let status = match require_str(&args, "status") {
155        Ok(status) => status,
156        Err(error) => return error,
157    };
158    let summary = match require_str(&args, "summary") {
159        Ok(summary) => summary,
160        Err(error) => return error,
161    };
162    let verification_recipe = match optional_string(&args, "verification_recipe") {
163        Ok(recipe) => recipe,
164        Err(error) => return error,
165    };
166    let items = match parse_items(args.get("items")) {
167        Ok(items) => items,
168        Err(error) => return error,
169    };
170
171    let definition = WorksetDefinition {
172        id: id.clone(),
173        goal,
174        status,
175        summary,
176        verification_recipe,
177        items,
178    };
179
180    let store_path = runtime.store_path.clone();
181    let sid = session_id.clone();
182    let items_len = definition.items.len();
183    match tokio::task::spawn_blocking(move || store::define_workset(&store_path, &sid, &definition))
184        .await
185    {
186        Ok(Ok(())) => ToolResult {
187            content: format!("Saved workset '{}' with {} item(s).", id, items_len),
188            is_error: false,
189        },
190        Ok(Err(error)) => ToolResult {
191            content: format!("Error saving workset '{}': {}", id, error),
192            is_error: true,
193        },
194        Err(join_error) => ToolResult {
195            content: format!("Internal error saving workset '{}': {}", id, join_error),
196            is_error: true,
197        },
198    }
199}
200
201pub async fn execute_read(args: Value, runtime: &ToolRuntime) -> ToolResult {
202    let session_id = match require_session(runtime) {
203        Ok(session_id) => session_id.to_string(),
204        Err(error) => return error,
205    };
206    let id = match require_str(&args, "id") {
207        Ok(id) => id,
208        Err(error) => return error,
209    };
210
211    let store_path = runtime.store_path.clone();
212    let sid = session_id.clone();
213    let wid = id.clone();
214    match tokio::task::spawn_blocking(move || store::read_workset(&store_path, &sid, &wid)).await {
215        Ok(Ok(Some(workset))) => ToolResult {
216            content: store::render_workset_document(&workset),
217            is_error: false,
218        },
219        Ok(Ok(None)) => ToolResult {
220            content: format!("Workset '{}' does not exist in this session.", id),
221            is_error: true,
222        },
223        Ok(Err(error)) => ToolResult {
224            content: format!("Error reading workset '{}': {}", id, error),
225            is_error: true,
226        },
227        Err(join_error) => ToolResult {
228            content: format!("Internal error reading workset '{}': {}", id, join_error),
229            is_error: true,
230        },
231    }
232}
233
234pub async fn execute_list(_args: Value, runtime: &ToolRuntime) -> ToolResult {
235    let session_id = match require_session(runtime) {
236        Ok(session_id) => session_id.to_string(),
237        Err(error) => return error,
238    };
239
240    let store_path = runtime.store_path.clone();
241    let sid = session_id.clone();
242    match tokio::task::spawn_blocking(move || store::list_worksets(&store_path, &sid)).await {
243        Ok(Ok(worksets)) => ToolResult {
244            content: store::render_workset_list(&worksets),
245            is_error: false,
246        },
247        Ok(Err(error)) => ToolResult {
248            content: format!("Error listing worksets: {}", error),
249            is_error: true,
250        },
251        Err(join_error) => ToolResult {
252            content: format!("Internal error listing worksets: {}", join_error),
253            is_error: true,
254        },
255    }
256}
257
258pub async fn execute_update_item(args: Value, runtime: &ToolRuntime) -> ToolResult {
259    let session_id = match require_session(runtime) {
260        Ok(session_id) => session_id.to_string(),
261        Err(error) => return error,
262    };
263    let id = match require_str(&args, "id") {
264        Ok(id) => id,
265        Err(error) => return error,
266    };
267    let title = match require_str(&args, "title") {
268        Ok(title) => title,
269        Err(error) => return error,
270    };
271    let status = match require_str(&args, "status") {
272        Ok(status) => status,
273        Err(error) => return error,
274    };
275    let notes = match optional_string(&args, "notes") {
276        Ok(notes) => notes,
277        Err(error) => return error,
278    };
279
280    match status.as_str() {
281        "planned" | "running" | "blocked" | "done" => {}
282        _ => {
283            return ToolResult {
284                content: format!(
285                    "Error: invalid status '{}'. Must be one of: planned, running, blocked, done",
286                    status
287                ),
288                is_error: true,
289            };
290        }
291    }
292
293    let store_path = runtime.store_path.clone();
294    let sid = session_id.clone();
295    let wid = id.clone();
296    let t = title.clone();
297    let s = status.clone();
298    let n = notes.clone();
299    match tokio::task::spawn_blocking(move || {
300        store::update_workset_item(&store_path, &sid, &wid, &t, &s, n.as_deref())
301    })
302    .await
303    {
304        Ok(Ok(true)) => ToolResult {
305            content: format!(
306                "Updated item '{}' in workset '{}' to status '{}'",
307                title, id, status
308            ),
309            is_error: false,
310        },
311        Ok(Ok(false)) => ToolResult {
312            content: format!("No item '{}' found in workset '{}'", title, id),
313            is_error: true,
314        },
315        Ok(Err(error)) => ToolResult {
316            content: format!(
317                "Error updating item '{}' in workset '{}': {}",
318                title, id, error
319            ),
320            is_error: true,
321        },
322        Err(join_error) => ToolResult {
323            content: format!(
324                "Internal error updating item '{}' in workset '{}': {}",
325                title, id, join_error
326            ),
327            is_error: true,
328        },
329    }
330}
331
332fn def(name: &str, description: &str, parameters: serde_json::Value) -> ToolDefinition {
333    ToolDefinition {
334        def_type: "function".to_string(),
335        function: crate::types::FunctionDef {
336            name: name.to_string(),
337            description: description.to_string(),
338            parameters,
339        },
340    }
341}
342
343fn require_session(runtime: &ToolRuntime) -> Result<&str, ToolResult> {
344    runtime.session_id.as_deref().ok_or_else(|| ToolResult {
345        content: "Error: workset tools require an active session".to_string(),
346        is_error: true,
347    })
348}
349
350fn optional_string(args: &Value, key: &str) -> Result<Option<String>, ToolResult> {
351    match args.get(key) {
352        None | Some(Value::Null) => Ok(None),
353        Some(Value::String(value)) => Ok(Some(value.clone())),
354        Some(_) => Err(ToolResult {
355            content: format!("Error: '{}' must be a string", key),
356            is_error: true,
357        }),
358    }
359}
360
361fn parse_items(value: Option<&Value>) -> Result<Vec<WorksetItemDefinition>, ToolResult> {
362    let Some(value) = value else {
363        return Err(ToolResult {
364            content: "Error: 'items' is required".to_string(),
365            is_error: true,
366        });
367    };
368    let Some(items) = value.as_array() else {
369        return Err(ToolResult {
370            content: "Error: 'items' must be an array".to_string(),
371            is_error: true,
372        });
373    };
374
375    let mut parsed = Vec::with_capacity(items.len());
376    for item in items {
377        let title = match require_item_str(item, "title") {
378            Ok(value) => value,
379            Err(error) => return Err(error),
380        };
381        let scope = match require_item_str(item, "scope") {
382            Ok(value) => value,
383            Err(error) => return Err(error),
384        };
385        let description = match require_item_str(item, "description") {
386            Ok(value) => value,
387            Err(error) => return Err(error),
388        };
389        let role = match require_item_str(item, "role") {
390            Ok(value) => value,
391            Err(error) => return Err(error),
392        };
393        let depends_on = match require_string_array(item, "depends_on") {
394            Ok(value) => value,
395            Err(error) => return Err(error),
396        };
397        let acceptance = match require_item_str(item, "acceptance") {
398            Ok(value) => value,
399            Err(error) => return Err(error),
400        };
401        let notes = match optional_string(item, "notes") {
402            Ok(value) => value,
403            Err(error) => return Err(error),
404        };
405        let status = match optional_string(item, "status") {
406            Ok(value) => value,
407            Err(error) => return Err(error),
408        };
409        parsed.push(WorksetItemDefinition {
410            title,
411            scope,
412            description,
413            role,
414            depends_on,
415            acceptance,
416            notes,
417            status,
418        });
419    }
420
421    Ok(parsed)
422}
423
424fn require_item_str(value: &Value, key: &str) -> Result<String, ToolResult> {
425    value
426        .get(key)
427        .and_then(Value::as_str)
428        .map(ToString::to_string)
429        .ok_or_else(|| ToolResult {
430            content: format!("Error: workset item '{}' is required", key),
431            is_error: true,
432        })
433}
434
435fn require_string_array(value: &Value, key: &str) -> Result<Vec<String>, ToolResult> {
436    let Some(value) = value.get(key) else {
437        return Err(ToolResult {
438            content: format!("Error: '{}' is required", key),
439            is_error: true,
440        });
441    };
442    let Some(items) = value.as_array() else {
443        return Err(ToolResult {
444            content: format!("Error: '{}' must be an array of strings", key),
445            is_error: true,
446        });
447    };
448    let mut parsed = Vec::with_capacity(items.len());
449    for item in items {
450        let Some(value) = item.as_str() else {
451            return Err(ToolResult {
452                content: format!("Error: '{}' must be an array of strings", key),
453                is_error: true,
454            });
455        };
456        parsed.push(value.to_string());
457    }
458    Ok(parsed)
459}