1mod schema;
4
5use std::collections::{BTreeMap, BTreeSet};
6use std::fmt;
7
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10
11use self::schema::{normalize_schema, schema_is_subset};
12use crate::{ActivityDescriptor, WorkerContract};
13
14#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
16pub struct ContractDiff {
17 pub package_version: String,
19 pub action: String,
21 pub field: String,
23 pub expected: Option<Value>,
25 pub advertised: Option<Value>,
27}
28
29impl fmt::Display for ContractDiff {
30 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31 write!(
32 formatter,
33 "package `{}` action `{}` field `{}` expected {} but worker advertised {}",
34 self.package_version,
35 self.action,
36 self.field,
37 rendered_value(self.expected.as_ref()),
38 rendered_value(self.advertised.as_ref()),
39 )
40 }
41}
42
43#[must_use]
56pub fn contract_diffs(
57 package_version: &str,
58 contract: &WorkerContract,
59 worker_node: Option<&str>,
60 advertised: &[ActivityDescriptor],
61) -> Vec<ContractDiff> {
62 let advertised = advertised
63 .iter()
64 .map(|activity| (activity.name.as_str(), activity))
65 .collect::<BTreeMap<_, _>>();
66 let mut expected = contract
72 .actions
73 .iter()
74 .filter(|action| action.worker_owed())
75 .filter(|action| dispatch_can_reach(action.node.as_deref(), worker_node))
76 .collect::<Vec<_>>();
77 expected.sort_by(|left, right| left.name.cmp(&right.name));
78 let mut diffs = Vec::new();
79 for action in expected {
80 let Some(actual) = advertised.get(action.name.as_str()) else {
81 diffs.push(ContractDiff {
82 package_version: package_version.to_owned(),
83 action: action.name.clone(),
84 field: "action".to_owned(),
85 expected: Some(Value::String(missing_action_requirement(
86 action.node.as_deref(),
87 ))),
88 advertised: None,
89 });
90 continue;
91 };
92
93 let expected_input = normalize_schema(&action.input_schema);
94 let advertised_input = normalize_schema(&actual.input_schema);
95 if !schema_is_subset(&expected_input, &advertised_input) {
96 diff_schema(
97 package_version,
98 &action.name,
99 "input_schema",
100 &expected_input,
101 &advertised_input,
102 &mut diffs,
103 );
104 }
105
106 let expected_output = normalize_schema(&action.output_schema);
107 let advertised_output = normalize_schema(&actual.output_schema);
108 if !schema_is_subset(&advertised_output, &expected_output) {
109 diff_schema(
110 package_version,
111 &action.name,
112 "output_schema",
113 &expected_output,
114 &advertised_output,
115 &mut diffs,
116 );
117 }
118 }
119 diffs
120}
121
122#[must_use]
147fn dispatch_can_reach(action_node: Option<&str>, worker_node: Option<&str>) -> bool {
148 match action_node {
149 None => true,
150 Some(pin) => worker_node == Some(pin),
151 }
152}
153
154fn missing_action_requirement(action_node: Option<&str>) -> String {
160 match action_node {
161 None => "advertised: the action is unpinned, so every worker in the pool must serve it"
162 .to_owned(),
163 Some(node) => format!("advertised: the action is pinned to node `{node}`"),
164 }
165}
166
167fn diff_schema(
168 package_version: &str,
169 action: &str,
170 field: &str,
171 expected: &Value,
172 advertised: &Value,
173 diffs: &mut Vec<ContractDiff>,
174) {
175 if schemas_equal(expected, advertised, None) {
176 return;
177 }
178 match (expected, advertised) {
179 (Value::Object(expected), Value::Object(advertised)) => {
180 let keys = expected
181 .keys()
182 .chain(advertised.keys())
183 .collect::<BTreeSet<_>>();
184 for key in keys {
185 let nested = format!("{field}.{key}");
186 match (expected.get(key), advertised.get(key)) {
187 (Some(left), Some(right)) => {
188 diff_schema(package_version, action, &nested, left, right, diffs);
189 }
190 (left, right) => diffs.push(ContractDiff {
191 package_version: package_version.to_owned(),
192 action: action.to_owned(),
193 field: nested,
194 expected: left.cloned(),
195 advertised: right.cloned(),
196 }),
197 }
198 }
199 }
200 _ => diffs.push(ContractDiff {
201 package_version: package_version.to_owned(),
202 action: action.to_owned(),
203 field: field.to_owned(),
204 expected: Some(expected.clone()),
205 advertised: Some(advertised.clone()),
206 }),
207 }
208}
209
210fn schemas_equal(left: &Value, right: &Value, parent: Option<&str>) -> bool {
211 match (left, right) {
212 (Value::Object(left), Value::Object(right)) => {
213 left.len() == right.len()
214 && left.iter().all(|(key, value)| {
215 right
216 .get(key)
217 .is_some_and(|other| schemas_equal(value, other, Some(key)))
218 })
219 }
220 (Value::Array(left), Value::Array(right))
221 if matches!(parent, Some("required" | "enum" | "type")) =>
222 {
223 let mut left = left.iter().map(stable_json).collect::<Vec<_>>();
224 let mut right = right.iter().map(stable_json).collect::<Vec<_>>();
225 left.sort();
226 right.sort();
227 left == right
228 }
229 (Value::Array(left), Value::Array(right)) => {
230 left.len() == right.len()
231 && left
232 .iter()
233 .zip(right)
234 .all(|(left, right)| schemas_equal(left, right, None))
235 }
236 _ => left == right,
237 }
238}
239
240fn stable_json(value: &Value) -> String {
241 match value {
242 Value::Object(values) => {
243 let fields = values
244 .iter()
245 .map(|(key, value)| format!("{key}:{}", stable_json(value)))
246 .collect::<Vec<_>>();
247 format!("{{{}}}", fields.join(","))
248 }
249 Value::Array(values) => {
250 let values = values.iter().map(stable_json).collect::<Vec<_>>();
251 format!("[{}]", values.join(","))
252 }
253 _ => value.to_string(),
254 }
255}
256
257fn rendered_value(value: Option<&Value>) -> String {
258 value.map_or_else(|| "<missing>".to_owned(), Value::to_string)
259}
260
261#[cfg(test)]
262mod tests {
263 use serde_json::json;
264
265 use super::contract_diffs;
266 use crate::{ActionContract, ActivityDescriptor, WorkerContract};
267 use serde_json::Value;
268
269 fn contract(input: serde_json::Value, output: serde_json::Value) -> WorkerContract {
270 WorkerContract {
271 task_queue: "payments".to_owned(),
272 actions: vec![ActionContract {
273 name: "charge".to_owned(),
274 input_schema: input,
275 output_schema: output,
276 node: None,
277 timeout: None,
278 retry: None,
279 advisory: false,
280 agent: false,
281 body: None,
282 }],
283 }
284 }
285
286 fn advertised(input: serde_json::Value, output: serde_json::Value) -> Vec<ActivityDescriptor> {
287 vec![ActivityDescriptor {
288 name: "charge".to_owned(),
289 input_schema: input,
290 output_schema: output,
291 }]
292 }
293
294 #[test]
295 fn mismatch_reports_the_exact_schema_field() {
296 let contract = contract(
297 json!({"type":"object","properties":{"amount":{"type":"integer"}}}),
298 json!({"type":"boolean"}),
299 );
300 let advertised = advertised(
301 json!({"properties":{"amount":{"type":"string"}},"type":"object"}),
302 json!({"type":"boolean"}),
303 );
304
305 let diffs = contract_diffs("abc", &contract, None, &advertised);
306 assert_eq!(diffs.len(), 1);
307 assert_eq!(diffs[0].field, "input_schema.properties.amount.type");
308 assert_eq!(diffs[0].expected, Some(json!("integer")));
309 assert_eq!(diffs[0].advertised, Some(json!("string")));
310 }
311
312 #[test]
313 fn input_widening_and_optional_output_addition_are_compatible() {
314 let contract = contract(
315 json!({
316 "$schema":"https://json-schema.org/draft/2020-12/schema",
317 "type":"object",
318 "properties":{"amount":{"type":"integer"}},
319 "required":["amount"]
320 }),
321 json!({
322 "type":"object",
323 "properties":{"approved":{"type":"boolean"}},
324 "required":["approved"]
325 }),
326 );
327 let advertised = advertised(
328 json!({
329 "title":"ChargeInput",
330 "type":"object",
331 "properties":{"amount":{"type":"number"}},
332 "required":["amount"]
333 }),
334 json!({
335 "title":"ChargeOutput",
336 "type":"object",
337 "properties":{
338 "approved":{"type":"boolean"},
339 "receipt":{"type":"string"}
340 },
341 "required":["approved"]
342 }),
343 );
344
345 assert!(contract_diffs("abc", &contract, None, &advertised).is_empty());
346 }
347
348 #[test]
349 fn input_narrowing_and_output_widening_are_refused() {
350 let contract = contract(json!({"type":"number"}), json!({"type":"integer"}));
351 let advertised = advertised(json!({"type":"integer"}), json!({"type":"number"}));
352
353 let diffs = contract_diffs("abc", &contract, None, &advertised);
354 assert_eq!(diffs.len(), 2);
355 assert_eq!(diffs[0].field, "input_schema.type");
356 assert_eq!(diffs[1].field, "output_schema.type");
357 }
358
359 #[test]
360 fn local_defs_and_inline_schemas_compare_semantically() {
361 let contract = contract(
362 json!({
363 "type":"object",
364 "properties":{"card":{"$ref":"#/$defs/Card"}},
365 "required":["card"],
366 "$defs":{"Card":{"type":"object","properties":{"last4":{"type":"string"}},"required":["last4"]}}
367 }),
368 json!({"type":"boolean"}),
369 );
370 let advertised = advertised(
371 json!({
372 "type":"object",
373 "properties":{"card":{"type":"object","properties":{"last4":{"type":"string"}},"required":["last4"]}},
374 "required":["card"]
375 }),
376 json!({"type":"boolean"}),
377 );
378
379 assert!(contract_diffs("abc", &contract, None, &advertised).is_empty());
380 }
381
382 #[test]
389 fn a_comment_in_a_declared_schema_does_not_have_to_be_reproduced() {
390 let contract = contract(
391 json!({
392 "type":"object",
393 "$comment":"amount is in the smallest currency unit",
394 "properties":{"amount":{"type":"integer","$comment":"cents"}},
395 "required":["amount"]
396 }),
397 json!({"type":"boolean","$comment":"true when the charge settled"}),
398 );
399 let advertised = advertised(
400 json!({
401 "type":"object",
402 "properties":{"amount":{"type":"integer"}},
403 "required":["amount"]
404 }),
405 json!({"type":"boolean"}),
406 );
407
408 assert!(
409 contract_diffs("abc", &contract, None, &advertised).is_empty(),
410 "a non-validating comment must not decide contract admission"
411 );
412 }
413
414 #[test]
420 fn a_declared_body_is_not_required_of_a_worker() {
421 let contract = WorkerContract {
422 task_queue: "python_box".to_owned(),
423 actions: vec![
424 ActionContract {
425 name: "inspect".to_owned(),
426 input_schema: json!({"type":"object"}),
427 output_schema: json!({"type":"boolean"}),
428 node: None,
429 timeout: None,
430 retry: None,
431 advisory: false,
432 agent: false,
433 body: None,
434 },
435 ActionContract {
436 name: "snapshot".to_owned(),
437 input_schema: json!({"type":"object"}),
438 output_schema: json!({"type":"boolean"}),
439 node: None,
440 timeout: None,
441 retry: None,
442 advisory: false,
443 agent: false,
444 body: Some(crate::ActionBodyContract::Run {
445 command: "git rev-parse HEAD".to_owned(),
446 }),
447 },
448 ],
449 };
450 let advertised = vec![ActivityDescriptor {
452 name: "inspect".to_owned(),
453 input_schema: json!({"type":"object"}),
454 output_schema: json!({"type":"boolean"}),
455 }];
456
457 assert!(
458 contract_diffs("abc", &contract, None, &advertised).is_empty(),
459 "a server-executed declared body must not be demanded of a worker"
460 );
461 }
462
463 fn node_partitioned_contract() -> WorkerContract {
465 let action = |name: &str, node: Option<&str>| ActionContract {
466 name: name.to_owned(),
467 input_schema: json!({"type":"object"}),
468 output_schema: json!({"type":"boolean"}),
469 node: node.map(str::to_owned),
470 timeout: None,
471 retry: None,
472 advisory: false,
473 agent: false,
474 body: None,
475 };
476 WorkerContract {
477 task_queue: "staged_rounds".to_owned(),
478 actions: vec![
479 action("gate_item", Some("shell")),
480 action("dev_item", Some("developer")),
481 action("review_item", Some("reviewer")),
482 action("audit", None),
483 ],
484 }
485 }
486
487 fn descriptor(name: &str) -> ActivityDescriptor {
488 ActivityDescriptor {
489 name: name.to_owned(),
490 input_schema: json!({"type":"object"}),
491 output_schema: json!({"type":"boolean"}),
492 }
493 }
494
495 #[test]
503 fn a_node_partitioned_connection_is_not_demanded_another_nodes_actions() {
504 let contract = node_partitioned_contract();
505 let shell = vec![descriptor("gate_item"), descriptor("audit")];
506
507 assert!(
508 contract_diffs("abc", &contract, Some("shell"), &shell).is_empty(),
509 "the shell connection serves its own node's actions and the unpinned \
510 one — it must not be refused for omitting the developer and reviewer \
511 nodes' actions"
512 );
513 }
514
515 #[test]
520 fn an_action_pinned_to_this_node_is_still_demanded() {
521 let contract = node_partitioned_contract();
522 let short = vec![descriptor("audit")];
524
525 let diffs = contract_diffs("abc", &contract, Some("shell"), &short);
526 assert_eq!(diffs.len(), 1, "{diffs:?}");
527 assert_eq!(diffs[0].action, "gate_item");
528 assert_eq!(
529 diffs[0].expected,
530 Some(Value::String(
531 "advertised: the action is pinned to node `shell`".to_owned()
532 ))
533 );
534 }
535
536 #[test]
542 fn an_unpinned_action_is_demanded_of_every_node() {
543 let contract = node_partitioned_contract();
544 let developer_only = vec![descriptor("dev_item")];
545
546 let diffs = contract_diffs("abc", &contract, Some("developer"), &developer_only);
547 assert_eq!(diffs.len(), 1, "{diffs:?}");
548 assert_eq!(diffs[0].action, "audit");
549 assert_eq!(
550 diffs[0].expected,
551 Some(Value::String(
552 "advertised: the action is unpinned, so every worker in the pool \
553 must serve it"
554 .to_owned()
555 ))
556 );
557 }
558
559 #[test]
563 fn a_node_less_connection_owes_only_the_unpinned_actions() {
564 let contract = node_partitioned_contract();
565
566 assert!(
567 contract_diffs("abc", &contract, None, &[descriptor("audit")]).is_empty(),
568 "a node-less connection is unreachable for pinned dispatches"
569 );
570 let diffs = contract_diffs("abc", &contract, None, &[]);
571 assert_eq!(diffs.len(), 1, "{diffs:?}");
572 assert_eq!(
573 diffs[0].action, "audit",
574 "the unpinned action is still owed by a node-less connection"
575 );
576 }
577
578 #[test]
583 fn a_connection_on_an_unknown_node_owes_only_the_unpinned_actions() {
584 let contract = node_partitioned_contract();
585
586 assert!(
587 contract_diffs("abc", &contract, Some("stranger"), &[descriptor("audit")]).is_empty(),
588 "an unknown node is unreachable for every pinned action"
589 );
590 }
591}