返回 CodeWhale
plan_schema.rs
根目录 / crates / tui / src / tools / workflow / plan_schema.rs
1 //! Replaces the bare `WorkflowTool.plan` object with a provider-facing schema
2 //! for the common structured Workflow launch path.
3
4 use serde_json::{Value, json};
5
6 /// Keep this shape aligned with `StructuredWorkflowPlan`, `StructuredPlanPhase`,
7 /// `StructuredPlanChild`, and `GateSpec`. The runtime's legacy aliases and IR
8 /// parser remain available; the advertised path needs no JavaScript authoring.
9 pub(super) fn structured_plan_schema() -> Value {
10 let child = json!({
11 "type": "object",
12 "properties": {
13 "id": {
14 "type": "string",
15 "description": "Stable child id. Defaults to label, then a generated phase-local id."
16 },
17 "label": {
18 "type": "string",
19 "description": "Short child label, also used as its id when id is absent."
20 },
21 "prompt": {
22 "type": "string",
23 "minLength": 1,
24 "description": "Concrete assignment and expected result for this child."
25 },
26 "type": {
27 "type": "string",
28 "enum": ["general", "explore", "planner", "reviewer", "implement", "test"],
29 "description": "Optional worker type. Prefer role/profile for Fleet steps; do not combine them with a conflicting type. Legacy type aliases remain accepted by the runtime."
30 },
31 "role": {
32 "type": "string",
33 "description": "Fleet role name. The Fleet supplies its route and authority."
34 },
35 "profile": {
36 "type": "string",
37 "description": "Worker profile name, resolved with the selected Fleet and caller policy."
38 },
39 "model": {
40 "type": "string",
41 "description": "Optional model selector from agent(action=roster)'s saved shortlist. Omit to use the selected role/profile route. Exact Fleets fix each member's model, so select the member instead of overriding it."
42 },
43 "mode": {
44 "type": "string",
45 "enum": ["read_only", "read_write"],
46 "description": "Requested child mode. Defaults to the plan risk; it cannot increase caller authority."
47 },
48 "file_scope": {
49 "type": "array",
50 "items": { "type": "string" },
51 "description": "Workspace-relative file scopes for this child. Use [] when no narrower scope is requested."
52 },
53 "cwd": {
54 "type": "string",
55 "description": "Optional repository-relative working directory for this child. Required in multi-repository workspaces so the child (and worktree isolation) resolves the right repository."
56 }
57 },
58 "required": ["prompt", "file_scope"],
59 "additionalProperties": false
60 });
61 let phase = json!({
62 "type": "object",
63 "properties": {
64 "id": {
65 "type": "string",
66 "description": "Stable phase id. Defaults to title, then a generated phase id."
67 },
68 "title": {
69 "type": "string",
70 "description": "Short phase title."
71 },
72 "parallel": {
73 "type": "boolean",
74 "description": "Run independent children concurrently. Defaults to true for multiple children; use false when they depend on each other."
75 },
76 "children": {
77 "type": "array",
78 "minItems": 1,
79 "items": child.clone(),
80 "description": "At least one child. Phases run in order and receive prior-phase results; missing required results block downstream dispatch."
81 }
82 },
83 "required": ["children"],
84 "additionalProperties": false
85 });
86 let gate = json!({
87 "type": "object",
88 "properties": {
89 "id": { "type": "string" },
90 "role": {
91 "type": "string",
92 "description": "Role whose lifecycle triggers this gate."
93 },
94 "on": {
95 "type": "string",
96 "enum": ["role_complete"]
97 },
98 "gate": {
99 "type": "string",
100 "enum": ["verify", "review", "approve"]
101 },
102 "on_fail": {
103 "type": "string",
104 "enum": ["retry", "block", "escalate"]
105 },
106 "blocks_role": {
107 "type": "string",
108 "description": "Downstream role blocked until the gate passes."
109 },
110 "max_retries": {
111 "type": "integer",
112 "minimum": 0,
113 "maximum": u32::MAX,
114 "default": 1,
115 "description": "Maximum retries before escalation. Use 1 for the runtime default."
116 },
117 "artifact_kind": {
118 "type": "string",
119 "description": "Optional handoff artifact kind, such as findings or verify_report."
120 },
121 "require_explicit_verdict": {
122 "type": "boolean",
123 "default": false,
124 "description": "Require a standalone first-line PASS, APPROVE, BLOCK, or FAIL verdict. False preserves completion-based gate evaluation."
125 }
126 },
127 "required": ["id", "role", "on", "gate", "on_fail", "max_retries", "require_explicit_verdict"],
128 "additionalProperties": false
129 });
130
131 // Strict providers require every property and make optional properties
132 // nullable. Serde's default Vec/bool/u32 fields accept omission but reject
133 // null, so advertise their concrete empty/default values as required.
134 json!({
135 "type": "object",
136 "description": "Structured Workflow plan. Provide goal and either ordered phases or parallel top-level children; use [] for unused collections. No JavaScript is required. Role/profile steps use the selected Fleet. Advanced Workflow IR remains available through script/source_path.",
137 "properties": {
138 "goal": {
139 "type": "string",
140 "minLength": 1,
141 "description": "Non-empty goal for the complete workflow."
142 },
143 "risk": {
144 "type": "string",
145 "enum": ["read_only", "writes", "elevated"],
146 "description": "Plan risk and default child mode. Defaults to read_only; writes/elevated remain subject to approval and caller policy."
147 },
148 "max_children": {
149 "type": "integer",
150 "minimum": 1,
151 "description": "Optional maximum total declared children across all phases."
152 },
153 "token_budget": {
154 "type": "integer",
155 "minimum": 1,
156 "description": "Optional Workflow token budget, also applied to child admission. Usage is reconciled at completion; active parallel children can exceed the shared hint."
157 },
158 "phases": {
159 "type": "array",
160 "items": phase,
161 "description": "Ordered phases with prior-result handoff. Use [] for a flat children plan."
162 },
163 "children": {
164 "type": "array",
165 "items": child,
166 "description": "Independent children run in parallel when phases is empty. Use [] when phases are provided."
167 },
168 "gates": {
169 "type": "array",
170 "items": gate,
171 "description": "Role lifecycle gates. Use [] when no additional gates are needed."
172 }
173 },
174 "required": ["goal", "phases", "children", "gates"],
175 "additionalProperties": false
176 })
177 }
178
179 #[cfg(test)]
180 mod tests {
181 use super::structured_plan_schema;
182 use crate::tools::schema_sanitize::{sanitize, sanitize_for_strict};
183 use serde_json::{Value, json};
184
185 fn property_names(schema: &Value) -> Vec<&str> {
186 let mut names = schema["properties"]
187 .as_object()
188 .expect("explicit object properties")
189 .keys()
190 .map(String::as_str)
191 .collect::<Vec<_>>();
192 names.sort_unstable();
193 names
194 }
195
196 fn assert_strict_objects(schema: &Value) {
197 if schema["type"] == "object" {
198 let names = property_names(schema);
199 assert!(
200 !names.is_empty(),
201 "a closed empty object cannot carry a plan"
202 );
203 assert_eq!(schema["additionalProperties"], false);
204 let required = schema["required"].as_array().expect("required properties");
205 assert_eq!(required.len(), names.len());
206 for name in names {
207 assert!(required.contains(&json!(name)), "missing required {name}");
208 }
209 }
210 match schema {
211 Value::Object(object) => {
212 for value in object.values() {
213 assert_strict_objects(value);
214 }
215 }
216 Value::Array(array) => {
217 for value in array {
218 assert_strict_objects(value);
219 }
220 }
221 _ => {}
222 }
223 }
224
225 #[test]
226 fn structured_plan_properties_survive_general_and_strict_sanitizers() {
227 let mut schema = structured_plan_schema();
228 let original = schema.clone();
229 sanitize(&mut schema);
230 assert_eq!(schema, original);
231 sanitize_for_strict(&mut schema);
232
233 assert_eq!(
234 property_names(&schema),
235 [
236 "children",
237 "gates",
238 "goal",
239 "max_children",
240 "phases",
241 "risk",
242 "token_budget"
243 ]
244 );
245 let phase = &schema["properties"]["phases"]["items"];
246 assert_eq!(
247 property_names(phase),
248 ["children", "id", "parallel", "title"]
249 );
250 let child = &phase["properties"]["children"]["items"];
251 assert_eq!(
252 property_names(child),
253 [
254 "cwd",
255 "file_scope",
256 "id",
257 "label",
258 "mode",
259 "model",
260 "profile",
261 "prompt",
262 "role",
263 "type"
264 ]
265 );
266 assert_eq!(child, &schema["properties"]["children"]["items"]);
267 assert_strict_objects(&schema);
268 }
269
270 #[test]
271 fn strict_plan_only_marks_actual_option_fields_nullable() {
272 let mut schema = structured_plan_schema();
273 sanitize_for_strict(&mut schema);
274 let phase = &schema["properties"]["phases"]["items"];
275 let child = &phase["properties"]["children"]["items"];
276 let gate = &schema["properties"]["gates"]["items"];
277
278 for (object, names) in [
279 (&schema, &["goal", "phases", "children", "gates"][..]),
280 (phase, &["children"][..]),
281 (child, &["prompt", "file_scope"][..]),
282 (
283 gate,
284 &[
285 "id",
286 "role",
287 "on",
288 "gate",
289 "on_fail",
290 "max_retries",
291 "require_explicit_verdict",
292 ][..],
293 ),
294 ] {
295 for name in names {
296 assert!(
297 object["properties"][name].get("nullable").is_none(),
298 "{name} rejects null at runtime"
299 );
300 }
301 }
302 for (object, names) in [
303 (&schema, &["risk", "max_children", "token_budget"][..]),
304 (phase, &["id", "title", "parallel"][..]),
305 (
306 child,
307 &["id", "label", "type", "role", "profile", "model", "mode"][..],
308 ),
309 (gate, &["blocks_role", "artifact_kind"][..]),
310 ] {
311 for name in names {
312 assert_eq!(
313 object["properties"][name]["nullable"], true,
314 "{name} is an Option at runtime"
315 );
316 }
317 }
318 }
319
320 #[test]
321 fn strict_shaped_plan_with_null_options_lowers_without_javascript_authoring() {
322 let plan = json!({
323 "goal": "Inspect the release receipts",
324 "risk": null,
325 "max_children": null,
326 "token_budget": null,
327 "phases": [{
328 "id": null,
329 "title": null,
330 "parallel": null,
331 "children": [{
332 "id": null,
333 "label": null,
334 "prompt": "Read the existing receipts and report missing evidence.",
335 "type": null,
336 "role": null,
337 "profile": null,
338 "model": null,
339 "mode": null,
340 "file_scope": []
341 }]
342 }],
343 "children": [],
344 "gates": [{
345 "id": "review",
346 "role": "reviewer",
347 "on": "role_complete",
348 "gate": "review",
349 "on_fail": "block",
350 "blocks_role": null,
351 "max_retries": 1,
352 "artifact_kind": null,
353 "require_explicit_verdict": false
354 }]
355 });
356 let spec = super::super::structured_plan_to_workflow_spec(&plan)
357 .expect("strict provider values match runtime types");
358 assert_eq!(spec.goal, "Inspect the release receipts");
359 assert_eq!(spec.nodes.len(), 1);
360 assert_eq!(spec.gates.len(), 1);
361 assert_eq!(spec.gates[0].max_retries, 1);
362 let source = super::super::workflow_source_from_plan(&plan)
363 .expect("ordinary plans lower without caller-authored JavaScript");
364 assert!(source.spec.is_some());
365 assert!(!source.source.is_empty());
366 }
367
368 #[test]
369 fn typed_gate_schema_matches_serialized_gate_spec() {
370 let gate: codewhale_workflow::GateSpec = serde_json::from_value(json!({
371 "id": "verify",
372 "role": "verifier",
373 "on": "role_complete",
374 "gate": "verify",
375 "on_fail": "retry",
376 "blocks_role": "builder",
377 "max_retries": 2,
378 "artifact_kind": "verify_report",
379 "require_explicit_verdict": true
380 }))
381 .expect("existing GateSpec fields");
382 let serialized = serde_json::to_value(gate).expect("serialize gate");
383 let schema = structured_plan_schema();
384 let fields = property_names(&schema["properties"]["gates"]["items"]);
385 let mut serialized_fields = serialized
386 .as_object()
387 .expect("gate object")
388 .keys()
389 .map(String::as_str)
390 .collect::<Vec<_>>();
391 serialized_fields.sort_unstable();
392 assert_eq!(fields, serialized_fields);
393 }
394
395 #[test]
396 fn schema_does_not_remove_legacy_runtime_child_aliases_or_defaults() {
397 let spec = super::super::structured_plan_to_workflow_spec(&json!({
398 "goal": "Inspect the workspace",
399 "risk": "safe",
400 "children": [{
401 "description": "Report the existing behavior.",
402 "agent_type": "explorer",
403 "mode": "readonly"
404 }]
405 }))
406 .expect("legacy aliases and omitted collections remain runtime-compatible");
407 assert_eq!(spec.nodes.len(), 1);
408 assert!(spec.gates.is_empty());
409 }
410 }
411
411 lines RUST