1use serde::de::DeserializeOwned;
2use serde::{Deserialize, Serialize};
3use serde_json::{Value, json};
4use strum::IntoEnumIterator;
5
6use crate::store::WorkGraphEventFilter;
7use crate::types::{
8 AddEvidenceRequest, AttentionReassignRequest, ClaimWorkItemRequest, CloseWorkItemRequest,
9 LinkWorkItemsRequest, PolicyEscalateRequest, ReadyWorkFilter, ReleaseWorkItemRequest,
10 UpdateWorkItemRequest, WorkGraphSnapshotFilter, WorkItemFilter, WorkItemId, WorkNamespace,
11};
12use crate::{CreateWorkItemRequest, WorkGraphError, WorkGraphService};
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "snake_case")]
23pub enum WorkGraphToolErrorCode {
24 InvalidArguments,
25 NotFound,
26 CapabilityUnavailable,
27 Conflict,
28 InvalidTransition,
29 StoreError,
30 InternalError,
31}
32
33impl WorkGraphToolErrorCode {
34 pub const fn as_str(self) -> &'static str {
38 match self {
39 Self::InvalidArguments => "invalid_arguments",
40 Self::NotFound => "not_found",
41 Self::CapabilityUnavailable => "capability_unavailable",
42 Self::Conflict => "conflict",
43 Self::InvalidTransition => "invalid_transition",
44 Self::StoreError => "store_error",
45 Self::InternalError => "internal_error",
46 }
47 }
48}
49
50impl std::fmt::Display for WorkGraphToolErrorCode {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.write_str(self.as_str())
53 }
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
57pub struct WorkGraphToolError {
58 pub code: WorkGraphToolErrorCode,
59 pub message: String,
60}
61
62impl WorkGraphToolError {
63 fn new(code: WorkGraphToolErrorCode, message: impl Into<String>) -> Self {
64 Self {
65 code,
66 message: message.into(),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::EnumIter)]
80enum WorkGraphToolContract {
81 Create,
82 Get,
83 List,
84 Ready,
85 Snapshot,
86 Events,
87 Claim,
88 Release,
89 Update,
90 PolicyEscalate,
91 Block,
92 Close,
93 Link,
94 AddEvidence,
95 AttentionReassign,
96}
97
98impl WorkGraphToolContract {
99 const fn name(self) -> &'static str {
100 match self {
101 Self::Create => "workgraph_create",
102 Self::Get => "workgraph_get",
103 Self::List => "workgraph_list",
104 Self::Ready => "workgraph_ready",
105 Self::Snapshot => "workgraph_snapshot",
106 Self::Events => "workgraph_events",
107 Self::Claim => "workgraph_claim",
108 Self::Release => "workgraph_release",
109 Self::Update => "workgraph_update",
110 Self::PolicyEscalate => "workgraph_policy_escalate",
111 Self::Block => "workgraph_block",
112 Self::Close => "workgraph_close",
113 Self::Link => "workgraph_link",
114 Self::AddEvidence => "workgraph_add_evidence",
115 Self::AttentionReassign => "workgraph_attention_reassign",
116 }
117 }
118
119 const fn description(self) -> &'static str {
120 match self {
121 Self::Create => "Create a durable WorkGraph item.",
122 Self::Get => "Read one WorkGraph item.",
123 Self::List => "List WorkGraph items.",
124 Self::Ready => "List ready, claimable WorkGraph items.",
125 Self::Snapshot => "Read a WorkGraph observability snapshot.",
126 Self::Events => "Read WorkGraph event history.",
127 Self::Claim => "Claim a ready WorkGraph item with CAS revision checking.",
128 Self::Release => "Release a claimed WorkGraph item.",
129 Self::Update => "Update non-terminal WorkGraph item fields.",
130 Self::PolicyEscalate => "Monotonically tighten a WorkGraph completion policy.",
131 Self::Block => "Mark a WorkGraph item blocked.",
132 Self::Close => "Close a WorkGraph item with a terminal status.",
133 Self::Link => "Create a dependency or relationship edge.",
134 Self::AddEvidence => "Attach a typed evidence reference to a WorkGraph item.",
135 Self::AttentionReassign => "Reassign a WorkGraph attention binding.",
136 }
137 }
138
139 fn schema(self) -> Value {
140 match self {
141 Self::Create => create_schema(),
142 Self::Get => id_schema(false),
143 Self::List => list_schema(),
144 Self::Ready => ready_schema(),
145 Self::Snapshot => snapshot_schema(),
146 Self::Events => events_schema(),
147 Self::Claim => claim_schema(),
148 Self::Release | Self::Block => revision_id_schema(),
149 Self::Update => update_schema(),
150 Self::PolicyEscalate => policy_escalate_schema(),
151 Self::Close => close_schema(),
152 Self::Link => link_schema(),
153 Self::AddEvidence => evidence_schema(),
154 Self::AttentionReassign => attention_reassign_schema(),
155 }
156 }
157
158 const fn is_unscoped_surface_allowed(self) -> bool {
159 !matches!(self, Self::AttentionReassign | Self::PolicyEscalate)
160 }
161
162 fn parse(name: &str) -> Result<Self, WorkGraphToolError> {
163 Self::iter()
164 .find(|contract| contract.name() == name)
165 .ok_or_else(|| {
166 WorkGraphToolError::new(
167 WorkGraphToolErrorCode::NotFound,
168 format!("unknown WorkGraph tool '{name}'"),
169 )
170 })
171 }
172}
173
174pub fn workgraph_tools_list() -> Vec<Value> {
175 WorkGraphToolContract::iter()
176 .map(|contract| tool(contract.name(), contract.description(), contract.schema()))
177 .collect()
178}
179
180pub fn unscoped_workgraph_tools_list() -> Vec<Value> {
187 WorkGraphToolContract::iter()
188 .filter(|contract| contract.is_unscoped_surface_allowed())
189 .map(|contract| tool(contract.name(), contract.description(), contract.schema()))
190 .collect()
191}
192
193pub async fn handle_unscoped_workgraph_tools_call(
194 service: &WorkGraphService,
195 name: &str,
196 arguments: &Value,
197) -> Result<Value, WorkGraphToolError> {
198 let contract = WorkGraphToolContract::parse(name)?;
199 if !contract.is_unscoped_surface_allowed() {
200 return Err(WorkGraphToolError::new(
201 WorkGraphToolErrorCode::NotFound,
202 format!("unknown WorkGraph tool '{name}'"),
203 ));
204 }
205 handle_workgraph_tools_call(service, name, arguments).await
206}
207
208pub async fn handle_workgraph_tools_call(
209 service: &WorkGraphService,
210 name: &str,
211 arguments: &Value,
212) -> Result<Value, WorkGraphToolError> {
213 match WorkGraphToolContract::parse(name)? {
214 WorkGraphToolContract::Create => {
215 let request: CreateWorkItemRequest = parse(arguments)?;
216 service
217 .create(request)
218 .await
219 .map(|item| json!({ "item": item }))
220 .map_err(map_error)
221 }
222 WorkGraphToolContract::Get => {
223 let request: IdParams = parse(arguments)?;
224 service
225 .get(request.realm_id, request.namespace, request.id)
226 .await
227 .map(|item| json!({ "item": item }))
228 .map_err(map_error)
229 }
230 WorkGraphToolContract::List => {
231 let filter: WorkItemFilter = parse(arguments)?;
232 service
233 .list(filter)
234 .await
235 .map(|items| json!({ "items": items }))
236 .map_err(map_error)
237 }
238 WorkGraphToolContract::Ready => {
239 let filter: ReadyWorkFilter = parse(arguments)?;
240 service
241 .ready(filter)
242 .await
243 .map(|items| json!({ "items": items }))
244 .map_err(map_error)
245 }
246 WorkGraphToolContract::Snapshot => {
247 let filter: WorkGraphSnapshotFilter = parse(arguments)?;
248 service
249 .snapshot(filter)
250 .await
251 .map(|snapshot| json!({ "snapshot": snapshot }))
252 .map_err(map_error)
253 }
254 WorkGraphToolContract::Claim => {
255 let request: ClaimWorkItemRequest = parse(arguments)?;
256 service
257 .claim(request)
258 .await
259 .map(|item| json!({ "item": item }))
260 .map_err(map_error)
261 }
262 WorkGraphToolContract::Release => {
263 let request: ReleaseWorkItemRequest = parse(arguments)?;
264 service
265 .release(request)
266 .await
267 .map(|item| json!({ "item": item }))
268 .map_err(map_error)
269 }
270 WorkGraphToolContract::Update => {
271 let request: UpdateWorkItemRequest = parse(arguments)?;
272 service
273 .update(request)
274 .await
275 .map(|item| json!({ "item": item }))
276 .map_err(map_error)
277 }
278 WorkGraphToolContract::PolicyEscalate => {
279 let request: PolicyEscalateRequest = parse(arguments)?;
280 service
281 .escalate_policy(request)
282 .await
283 .map(|item| json!({ "item": item }))
284 .map_err(map_error)
285 }
286 WorkGraphToolContract::Block => {
287 let request: RevisionIdParams = parse(arguments)?;
288 service
289 .block(
290 request.realm_id,
291 request.namespace,
292 request.id,
293 request.expected_revision,
294 )
295 .await
296 .map(|item| json!({ "item": item }))
297 .map_err(map_error)
298 }
299 WorkGraphToolContract::Close => {
300 let request: CloseWorkItemRequest = parse(arguments)?;
301 service
302 .close(request)
303 .await
304 .map(|item| json!({ "item": item }))
305 .map_err(map_error)
306 }
307 WorkGraphToolContract::Link => {
308 let request: LinkWorkItemsRequest = parse(arguments)?;
309 service
310 .link(request)
311 .await
312 .map(|edge| json!({ "edge": edge }))
313 .map_err(map_error)
314 }
315 WorkGraphToolContract::AddEvidence => {
316 let request: AddEvidenceRequest = parse(arguments)?;
317 service
318 .add_evidence(request)
319 .await
320 .map(|item| json!({ "item": item }))
321 .map_err(map_error)
322 }
323 WorkGraphToolContract::AttentionReassign => {
324 let request: AttentionReassignRequest = parse(arguments)?;
325 service
326 .reassign_attention(request)
327 .await
328 .map(|result| json!({ "previous": result.previous, "attention": result.attention }))
329 .map_err(map_error)
330 }
331 WorkGraphToolContract::Events => {
332 let filter: WorkGraphEventFilterParams = parse(arguments)?;
333 service
334 .events(filter.into())
335 .await
336 .map(|events| json!({ "events": events }))
337 .map_err(map_error)
338 }
339 }
340}
341
342#[derive(Debug, Deserialize)]
343struct IdParams {
344 id: WorkItemId,
345 #[serde(default)]
346 realm_id: Option<String>,
347 #[serde(default)]
348 namespace: Option<WorkNamespace>,
349}
350
351#[derive(Debug, Deserialize)]
352struct RevisionIdParams {
353 id: WorkItemId,
354 expected_revision: u64,
355 #[serde(default)]
356 realm_id: Option<String>,
357 #[serde(default)]
358 namespace: Option<WorkNamespace>,
359}
360
361#[derive(Debug, Deserialize)]
362struct WorkGraphEventFilterParams {
363 #[serde(default)]
364 realm_id: Option<String>,
365 #[serde(default)]
366 namespace: Option<WorkNamespace>,
367 #[serde(default)]
368 all_namespaces: bool,
369 #[serde(default)]
370 after_seq: Option<i64>,
371 #[serde(default)]
372 limit: Option<usize>,
373}
374
375impl From<WorkGraphEventFilterParams> for WorkGraphEventFilter {
376 fn from(value: WorkGraphEventFilterParams) -> Self {
377 Self {
378 realm_id: value.realm_id,
379 namespace: value.namespace,
380 all_namespaces: value.all_namespaces,
381 after_seq: value.after_seq,
382 limit: value.limit,
383 }
384 }
385}
386
387fn parse<T: DeserializeOwned>(arguments: &Value) -> Result<T, WorkGraphToolError> {
388 serde_json::from_value(arguments.clone()).map_err(|err| {
389 WorkGraphToolError::new(
390 WorkGraphToolErrorCode::InvalidArguments,
391 format!("invalid WorkGraph arguments: {err}"),
392 )
393 })
394}
395
396fn map_error(error: WorkGraphError) -> WorkGraphToolError {
397 let code = match error {
398 WorkGraphError::NotFound { .. } | WorkGraphError::AttentionNotFound { .. } => {
399 WorkGraphToolErrorCode::NotFound
400 }
401 WorkGraphError::StaleRevision { .. } | WorkGraphError::Conflict(_) => {
402 WorkGraphToolErrorCode::Conflict
403 }
404 WorkGraphError::InvalidTransition(_) => WorkGraphToolErrorCode::InvalidTransition,
405 WorkGraphError::InvalidInput(_) | WorkGraphError::InvalidTimestampMillis { .. } => {
406 WorkGraphToolErrorCode::InvalidArguments
407 }
408 WorkGraphError::UnsupportedBackend(_) => WorkGraphToolErrorCode::CapabilityUnavailable,
409 WorkGraphError::Store(_) => WorkGraphToolErrorCode::StoreError,
410 };
411 WorkGraphToolError::new(code, error.to_string())
412}
413
414fn tool(name: &str, description: &str, schema: Value) -> Value {
415 json!({
416 "name": name,
417 "description": description,
418 "inputSchema": schema,
419 })
420}
421
422fn base_properties() -> serde_json::Map<String, Value> {
423 serde_json::Map::from_iter([
424 ("realm_id".to_string(), json!({ "type": "string" })),
425 ("namespace".to_string(), json!({ "type": "string" })),
426 ])
427}
428
429fn external_ref_schema() -> Value {
430 json!({
431 "type": "object",
432 "properties": {
433 "kind": { "type": "string" },
434 "id": { "type": "string" },
435 "url": { "type": "string" }
436 },
437 "required": ["kind", "id"],
438 "additionalProperties": false
439 })
440}
441
442fn evidence_ref_schema() -> Value {
443 json!({
444 "type": "object",
445 "properties": {
446 "kind": { "type": "string" },
447 "id": { "type": "string" },
448 "label": { "type": "string" },
449 "summary": { "type": "string" }
450 },
451 "required": ["kind", "id"],
452 "additionalProperties": false
453 })
454}
455
456fn owner_key_schema() -> Value {
457 json!({
458 "type": "object",
459 "properties": {
460 "kind": {
461 "type": "string",
462 "enum": ["principal", "agent", "session", "mob", "label"]
463 },
464 "id": { "type": "string" }
465 },
466 "required": ["kind", "id"],
467 "additionalProperties": false
468 })
469}
470
471fn goal_attention_target_schema() -> Value {
472 json!({
473 "oneOf": [
474 {
475 "type": "object",
476 "properties": {
477 "kind": { "const": "session" },
478 "session_id": { "type": "string" }
479 },
480 "required": ["kind", "session_id"],
481 "additionalProperties": false
482 },
483 {
484 "type": "object",
485 "properties": {
486 "kind": { "const": "owner" },
487 "owner_key": owner_key_schema()
488 },
489 "required": ["kind", "owner_key"],
490 "additionalProperties": false
491 }
492 ]
493 })
494}
495
496fn object(properties: serde_json::Map<String, Value>, required: &[&str]) -> Value {
497 json!({
498 "type": "object",
499 "properties": properties,
500 "required": required,
501 "additionalProperties": false,
502 })
503}
504
505fn id_schema(include_revision: bool) -> Value {
506 let mut properties = base_properties();
507 properties.insert("id".to_string(), json!({ "type": "string" }));
508 if include_revision {
509 properties.insert(
510 "expected_revision".to_string(),
511 json!({ "type": "integer", "minimum": 0 }),
512 );
513 object(properties, &["id", "expected_revision"])
514 } else {
515 object(properties, &["id"])
516 }
517}
518
519fn revision_id_schema() -> Value {
520 id_schema(true)
521}
522
523fn attention_reassign_schema() -> Value {
524 let mut properties = base_properties();
525 properties.extend([
526 ("binding_id".to_string(), json!({ "type": "string" })),
527 (
528 "expected_revision".to_string(),
529 json!({ "type": "integer", "minimum": 0 }),
530 ),
531 ("target".to_string(), goal_attention_target_schema()),
532 ]);
533 object(properties, &["binding_id", "expected_revision", "target"])
534}
535
536fn create_schema() -> Value {
537 let mut properties = base_properties();
538 properties.extend([
539 ("title".to_string(), json!({ "type": "string" })),
540 ("description".to_string(), json!({ "type": "string" })),
541 (
542 "priority".to_string(),
543 json!({ "type": "string", "enum": ["low", "medium", "high"] }),
544 ),
545 (
546 "labels".to_string(),
547 json!({ "type": "array", "items": { "type": "string" } }),
548 ),
549 (
550 "due_at".to_string(),
551 json!({ "type": "string", "format": "date-time" }),
552 ),
553 (
554 "not_before".to_string(),
555 json!({ "type": "string", "format": "date-time" }),
556 ),
557 (
558 "snoozed_until".to_string(),
559 json!({ "type": "string", "format": "date-time" }),
560 ),
561 (
562 "status".to_string(),
563 json!({ "type": "string", "enum": ["open", "blocked"] }),
564 ),
565 (
566 "external_refs".to_string(),
567 json!({ "type": "array", "items": external_ref_schema() }),
568 ),
569 (
570 "evidence_refs".to_string(),
571 json!({ "type": "array", "items": evidence_ref_schema() }),
572 ),
573 ]);
574 object(properties, &["title"])
575}
576
577fn list_schema() -> Value {
578 let mut properties = base_properties();
579 properties.extend([
580 ("all_namespaces".to_string(), json!({ "type": "boolean" })),
581 (
582 "statuses".to_string(),
583 json!({ "type": "array", "items": { "type": "string" } }),
584 ),
585 (
586 "labels".to_string(),
587 json!({ "type": "array", "items": { "type": "string" } }),
588 ),
589 ("include_terminal".to_string(), json!({ "type": "boolean" })),
590 (
591 "limit".to_string(),
592 json!({ "type": "integer", "minimum": 1 }),
593 ),
594 ]);
595 object(properties, &[])
596}
597
598fn ready_schema() -> Value {
599 let mut properties = base_properties();
600 properties.extend([
601 (
602 "labels".to_string(),
603 json!({ "type": "array", "items": { "type": "string" } }),
604 ),
605 (
606 "limit".to_string(),
607 json!({ "type": "integer", "minimum": 1 }),
608 ),
609 ]);
610 object(properties, &[])
611}
612
613fn snapshot_schema() -> Value {
614 list_schema()
615}
616
617fn events_schema() -> Value {
618 let mut properties = base_properties();
619 properties.extend([
620 ("all_namespaces".to_string(), json!({ "type": "boolean" })),
621 (
622 "after_seq".to_string(),
623 json!({ "type": "integer", "minimum": 0 }),
624 ),
625 (
626 "limit".to_string(),
627 json!({ "type": "integer", "minimum": 1 }),
628 ),
629 ]);
630 object(properties, &[])
631}
632
633fn claim_schema() -> Value {
634 let mut properties = base_properties();
635 properties.extend([
636 ("id".to_string(), json!({ "type": "string" })),
637 (
638 "expected_revision".to_string(),
639 json!({ "type": "integer", "minimum": 0 }),
640 ),
641 (
642 "owner".to_string(),
643 json!({
644 "type": "object",
645 "properties": {
646 "key": {
647 "type": "object",
648 "properties": {
649 "kind": {
650 "type": "string",
651 "enum": ["principal", "agent", "session", "mob", "label"]
652 },
653 "id": { "type": "string" }
654 },
655 "required": ["kind", "id"],
656 "additionalProperties": false
657 },
658 "display_name": { "type": "string" }
659 },
660 "required": ["key"],
661 "additionalProperties": false
662 }),
663 ),
664 (
665 "lease_seconds".to_string(),
666 json!({ "type": "integer", "minimum": 1 }),
667 ),
668 (
669 "lease_expires_at".to_string(),
670 json!({ "type": "string", "format": "date-time" }),
671 ),
672 ]);
673 object(properties, &["id", "expected_revision", "owner"])
674}
675
676fn update_schema() -> Value {
677 let mut properties = base_properties();
678 properties.extend([
679 ("id".to_string(), json!({ "type": "string" })),
680 (
681 "expected_revision".to_string(),
682 json!({ "type": "integer", "minimum": 0 }),
683 ),
684 ("title".to_string(), json!({ "type": "string" })),
685 ("description".to_string(), json!({ "type": "string" })),
686 (
687 "priority".to_string(),
688 json!({ "type": "string", "enum": ["low", "medium", "high"] }),
689 ),
690 (
691 "labels".to_string(),
692 json!({ "type": "array", "items": { "type": "string" } }),
693 ),
694 (
695 "due_at".to_string(),
696 json!({ "type": "string", "format": "date-time" }),
697 ),
698 (
699 "not_before".to_string(),
700 json!({ "type": "string", "format": "date-time" }),
701 ),
702 (
703 "snoozed_until".to_string(),
704 json!({ "type": "string", "format": "date-time" }),
705 ),
706 (
707 "external_refs".to_string(),
708 json!({ "type": "array", "items": external_ref_schema() }),
709 ),
710 ]);
711 object(properties, &["id", "expected_revision"])
712}
713
714fn completion_policy_schema() -> Value {
715 json!({
716 "oneOf": [
717 {
718 "type": "object",
719 "properties": { "kind": { "const": "self_attest" } },
720 "required": ["kind"],
721 "additionalProperties": false
722 },
723 {
724 "type": "object",
725 "properties": { "kind": { "const": "host_confirmed" } },
726 "required": ["kind"],
727 "additionalProperties": false
728 },
729 {
730 "type": "object",
731 "properties": { "kind": { "const": "principal_confirmed" } },
732 "required": ["kind"],
733 "additionalProperties": false
734 },
735 {
736 "type": "object",
737 "properties": {
738 "kind": { "const": "supervisor" },
739 "owner_key": owner_key_schema()
740 },
741 "required": ["kind", "owner_key"],
742 "additionalProperties": false
743 },
744 {
745 "type": "object",
746 "properties": {
747 "kind": { "const": "reviewer_quorum" },
748 "threshold": { "type": "integer", "minimum": 1, "maximum": 64 }
749 },
750 "required": ["kind", "threshold"],
751 "additionalProperties": false
752 }
753 ]
754 })
755}
756
757fn policy_escalate_schema() -> Value {
758 let mut properties = base_properties();
759 properties.extend([
760 ("id".to_string(), json!({ "type": "string" })),
761 (
762 "expected_revision".to_string(),
763 json!({ "type": "integer", "minimum": 0 }),
764 ),
765 ("completion_policy".to_string(), completion_policy_schema()),
766 ]);
767 object(
768 properties,
769 &["id", "expected_revision", "completion_policy"],
770 )
771}
772
773fn close_schema() -> Value {
774 let mut properties = base_properties();
775 properties.extend([
776 ("id".to_string(), json!({ "type": "string" })),
777 (
778 "expected_revision".to_string(),
779 json!({ "type": "integer", "minimum": 0 }),
780 ),
781 (
782 "status".to_string(),
783 json!({ "type": "string", "enum": ["completed", "cancelled", "failed"] }),
784 ),
785 ]);
786 object(properties, &["id", "expected_revision"])
787}
788
789fn link_schema() -> Value {
790 let mut properties = base_properties();
791 properties.extend([
792 (
793 "kind".to_string(),
794 json!({
795 "type": "string",
796 "enum": ["blocks", "parent", "related", "supersedes", "derived_from"]
797 }),
798 ),
799 ("from_id".to_string(), json!({ "type": "string" })),
800 ("to_id".to_string(), json!({ "type": "string" })),
801 ]);
802 object(properties, &["kind", "from_id", "to_id"])
803}
804
805fn evidence_schema() -> Value {
806 let mut properties = base_properties();
807 properties.extend([
808 ("id".to_string(), json!({ "type": "string" })),
809 (
810 "expected_revision".to_string(),
811 json!({ "type": "integer", "minimum": 0 }),
812 ),
813 ("evidence".to_string(), evidence_ref_schema()),
814 ]);
815 object(properties, &["id", "expected_revision", "evidence"])
816}
817
818#[cfg(test)]
819#[allow(clippy::expect_used, clippy::unwrap_used)]
820mod tests {
821 use std::collections::BTreeSet;
822 use std::sync::Arc;
823
824 use serde_json::json;
825
826 use crate::{MemoryWorkGraphStore, WorkGraphService, WorkNamespace};
827
828 use super::*;
829
830 #[tokio::test]
831 async fn workgraph_tools_create_and_ready_round_trip() {
832 let service = WorkGraphService::with_scope(
833 Arc::new(MemoryWorkGraphStore::new()),
834 "realm",
835 WorkNamespace::default(),
836 );
837 let created = handle_workgraph_tools_call(
838 &service,
839 "workgraph_create",
840 &json!({ "title": "tool item", "labels": ["a"] }),
841 )
842 .await
843 .expect("create");
844 let id = created["item"]["id"].as_str().expect("id").to_string();
845 let ready =
846 handle_workgraph_tools_call(&service, "workgraph_ready", &json!({ "labels": ["a"] }))
847 .await
848 .expect("ready");
849 assert_eq!(ready["items"][0]["id"].as_str(), Some(id.as_str()));
850 }
851
852 const CANONICAL_WORKGRAPH_TOOL_NAMES: &[&str] = &[
859 "workgraph_create",
860 "workgraph_get",
861 "workgraph_list",
862 "workgraph_ready",
863 "workgraph_snapshot",
864 "workgraph_events",
865 "workgraph_claim",
866 "workgraph_release",
867 "workgraph_update",
868 "workgraph_block",
869 "workgraph_close",
870 "workgraph_link",
871 "workgraph_add_evidence",
872 "workgraph_policy_escalate",
873 "workgraph_attention_reassign",
874 ];
875
876 const UNSCOPED_WORKGRAPH_TOOL_NAMES: &[&str] = &[
877 "workgraph_create",
878 "workgraph_get",
879 "workgraph_list",
880 "workgraph_ready",
881 "workgraph_snapshot",
882 "workgraph_events",
883 "workgraph_claim",
884 "workgraph_release",
885 "workgraph_update",
886 "workgraph_block",
887 "workgraph_close",
888 "workgraph_link",
889 "workgraph_add_evidence",
890 ];
891
892 #[test]
893 fn workgraph_tool_catalog_matches_canonical_operation_set_without_drift() {
894 let canonical = CANONICAL_WORKGRAPH_TOOL_NAMES
895 .iter()
896 .copied()
897 .map(ToString::to_string)
898 .collect::<BTreeSet<_>>();
899 assert_eq!(
900 canonical.len(),
901 CANONICAL_WORKGRAPH_TOOL_NAMES.len(),
902 "canonical WorkGraph operation names must be unique"
903 );
904
905 let catalog = WorkGraphToolContract::iter()
908 .map(|contract| contract.name().to_string())
909 .collect::<BTreeSet<_>>();
910 assert_eq!(
911 catalog.len(),
912 WorkGraphToolContract::iter().count(),
913 "WorkGraphToolContract variants must not share operation names"
914 );
915 assert_eq!(
916 catalog, canonical,
917 "derived WorkGraphToolContract catalog drifted from the canonical operation set"
918 );
919
920 let advertised = workgraph_tools_list()
922 .into_iter()
923 .filter_map(|tool| tool["name"].as_str().map(ToString::to_string))
924 .collect::<BTreeSet<_>>();
925 assert_eq!(
926 advertised, canonical,
927 "advertised WorkGraph tool list drifted from the canonical operation set"
928 );
929
930 for name in CANONICAL_WORKGRAPH_TOOL_NAMES {
934 let contract = WorkGraphToolContract::parse(name)
935 .expect("canonical WorkGraph operation must be dispatchable");
936 assert_eq!(
937 contract.name(),
938 *name,
939 "dispatch round-trip changed the operation name for {name}"
940 );
941 }
942 let unknown = WorkGraphToolContract::parse("workgraph_not_a_real_tool")
943 .expect_err("dispatch must reject operations outside the catalog");
944 assert_eq!(unknown.code, WorkGraphToolErrorCode::NotFound);
945 }
946
947 #[tokio::test]
948 async fn unscoped_workgraph_tools_exclude_attention_only_operations() {
949 let unscoped = unscoped_workgraph_tools_list()
950 .into_iter()
951 .filter_map(|tool| tool["name"].as_str().map(ToString::to_string))
952 .collect::<BTreeSet<_>>();
953 let expected = UNSCOPED_WORKGRAPH_TOOL_NAMES
954 .iter()
955 .copied()
956 .map(ToString::to_string)
957 .collect::<BTreeSet<_>>();
958 assert_eq!(unscoped, expected);
959 assert!(!unscoped.contains("workgraph_attention_reassign"));
960 assert!(!unscoped.contains("workgraph_policy_escalate"));
961
962 let service = WorkGraphService::new(Arc::new(MemoryWorkGraphStore::new()));
963 let err = handle_unscoped_workgraph_tools_call(
964 &service,
965 "workgraph_attention_reassign",
966 &json!({
967 "binding_id": "attn_1",
968 "expected_revision": 1,
969 "target": {
970 "kind": "owner",
971 "owner_key": { "kind": "agent", "id": "agent:mob/demo/agent/member" }
972 }
973 }),
974 )
975 .await
976 .expect_err("attention-only tool is not dispatchable from the unscoped surface");
977 assert_eq!(err.code, WorkGraphToolErrorCode::NotFound);
978
979 let err = handle_unscoped_workgraph_tools_call(
980 &service,
981 "workgraph_policy_escalate",
982 &json!({
983 "id": "item-1",
984 "expected_revision": 1,
985 "completion_policy": { "kind": "host_confirmed" }
986 }),
987 )
988 .await
989 .expect_err("policy escalation is not dispatchable from the unscoped surface");
990 assert_eq!(err.code, WorkGraphToolErrorCode::NotFound);
991 }
992
993 #[test]
994 fn workgraph_tool_schemas_do_not_expose_bare_arrays_or_objects() {
995 fn assert_schema_is_provider_safe(path: &str, schema: &Value) {
996 match schema {
997 Value::Object(map) => {
998 let is_array = map.get("type").and_then(Value::as_str) == Some("array");
999 assert!(
1000 !is_array || map.contains_key("items"),
1001 "{path} is an array schema without items"
1002 );
1003
1004 let is_object = map.get("type").and_then(Value::as_str) == Some("object");
1005 assert!(
1006 !is_object || map.contains_key("properties"),
1007 "{path} is an object schema without properties"
1008 );
1009
1010 for (key, value) in map {
1011 assert_schema_is_provider_safe(&format!("{path}.{key}"), value);
1012 }
1013 }
1014 Value::Array(items) => {
1015 for (index, value) in items.iter().enumerate() {
1016 assert_schema_is_provider_safe(&format!("{path}[{index}]"), value);
1017 }
1018 }
1019 _ => {}
1020 }
1021 }
1022
1023 for tool in workgraph_tools_list() {
1024 let name = tool["name"].as_str().expect("tool name");
1025 assert_schema_is_provider_safe(name, &tool["inputSchema"]);
1026 }
1027 }
1028}