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}