1use anyhow::{Result, ensure};
4use rusqlite::{OptionalExtension, TransactionBehavior, params};
5
6use crate::control::{
7 AdmissionConflict, AdmissionFences, AdmissionReceipt, ControlOperation, ControlScope,
8 ControlledStore, FenceFacts, OperationGrant, evaluate_fences,
9};
10use crate::routing::{ChannelKind, QueueBounds};
11
12use super::store::StrategyStore;
13use super::{queue, queue::QueueStoreError};
14
15#[derive(Clone, Debug)]
16pub struct DurableControlledStore {
17 store: StrategyStore,
18}
19
20impl DurableControlledStore {
21 pub fn from_store(store: StrategyStore) -> Self {
22 Self { store }
23 }
24
25 pub fn open(portable_root: &std::path::Path) -> Result<Self> {
26 Ok(Self::from_store(StrategyStore::open(portable_root)?))
27 }
28
29 pub fn store(&self) -> &StrategyStore {
30 &self.store
31 }
32
33 pub fn set_graph_revision(&self, graph_id: &str, revision: u64) -> Result<()> {
34 validate_id(graph_id)?;
35 self.store.with_connection(|connection| -> Result<()> {
36 connection.execute(
37 "INSERT INTO workflow_graph_state(graph_id, graph_revision, barrier_active)
38 VALUES (?1, ?2, 0)
39 ON CONFLICT(graph_id) DO UPDATE SET graph_revision=excluded.graph_revision",
40 params![graph_id, revision as i64],
41 )?;
42 Ok(())
43 })
44 }
45
46 pub fn set_node_control_revision(
47 &self,
48 graph_id: &str,
49 node_id: &str,
50 revision: u64,
51 ) -> Result<()> {
52 validate_id(graph_id)?;
53 validate_id(node_id)?;
54 self.store.with_connection(|connection| -> Result<()> {
55 connection.execute(
56 "INSERT INTO workflow_node_state(
57 graph_id, node_id, control_revision, generation, lifecycle_state,
58 pause_negotiating
59 ) VALUES (?1, ?2, ?3, 1, 'ready', 0)
60 ON CONFLICT(graph_id, node_id)
61 DO UPDATE SET control_revision=excluded.control_revision",
62 params![graph_id, node_id, revision as i64],
63 )?;
64 Ok(())
65 })
66 }
67
68 pub fn mark_invocation_settled(
69 &self,
70 graph_id: &str,
71 node_id: &str,
72 invocation_id: &str,
73 ) -> Result<()> {
74 validate_id(graph_id)?;
75 validate_id(node_id)?;
76 validate_id(invocation_id)?;
77 self.store.with_connection(|connection| -> Result<()> {
78 connection.execute(
79 "INSERT INTO workflow_invocations(graph_id, node_id, invocation_id, settled)
80 VALUES (?1, ?2, ?3, 1)
81 ON CONFLICT(graph_id, node_id, invocation_id) DO UPDATE SET settled=1",
82 params![graph_id, node_id, invocation_id],
83 )?;
84 Ok(())
85 })
86 }
87
88 pub fn mark_stop_requested(&self, graph_id: &str, target: &str) -> Result<()> {
89 validate_id(graph_id)?;
90 validate_id(target)?;
91 self.store.with_connection(|connection| -> Result<()> {
92 connection.execute(
93 "INSERT OR IGNORE INTO workflow_stop_requests(graph_id, target)
94 VALUES (?1, ?2)",
95 params![graph_id, target],
96 )?;
97 Ok(())
98 })
99 }
100
101 pub fn mark_pause_negotiating(&self, graph_id: &str, target: &str) -> Result<()> {
102 validate_id(graph_id)?;
103 validate_id(target)?;
104 self.store.with_connection(|connection| -> Result<()> {
105 connection.execute(
106 "INSERT INTO workflow_pause_requests(graph_id, target, active)
107 VALUES (?1, ?2, 1)
108 ON CONFLICT(graph_id, target) DO UPDATE SET active=1",
109 params![graph_id, target],
110 )?;
111 Ok(())
112 })
113 }
114
115 pub fn clear_pause_negotiating(&self, graph_id: &str, target: &str) -> Result<bool> {
116 validate_id(graph_id)?;
117 validate_id(target)?;
118 self.store.with_connection(|connection| {
119 Ok(connection.execute(
120 "UPDATE workflow_pause_requests SET active=0
121 WHERE graph_id=?1 AND target=?2 AND active=1",
122 params![graph_id, target],
123 )? == 1)
124 })
125 }
126
127 pub fn set_target_generation(
128 &self,
129 graph_id: &str,
130 target: &str,
131 generation: u64,
132 ) -> Result<()> {
133 validate_id(graph_id)?;
134 validate_id(target)?;
135 self.store.with_connection(|connection| -> Result<()> {
136 connection.execute(
137 "INSERT INTO workflow_node_state(
138 graph_id, node_id, control_revision, generation, lifecycle_state,
139 pause_negotiating
140 ) VALUES (?1, ?2, 0, ?3, 'ready', 0)
141 ON CONFLICT(graph_id, node_id)
142 DO UPDATE SET generation=excluded.generation",
143 params![graph_id, target, generation as i64],
144 )?;
145 Ok(())
146 })
147 }
148
149 pub fn set_graph_barrier(&self, graph_id: &str, active: bool) -> Result<()> {
150 validate_id(graph_id)?;
151 self.store.with_connection(|connection| -> Result<()> {
152 connection.execute(
153 "INSERT INTO workflow_graph_state(graph_id, graph_revision, barrier_active)
154 VALUES (?1, 1, ?2)
155 ON CONFLICT(graph_id) DO UPDATE SET barrier_active=excluded.barrier_active",
156 params![graph_id, i64::from(active)],
157 )?;
158 Ok(())
159 })
160 }
161
162 pub fn revoke_grant(&self, principal_id: &str, grant: &OperationGrant) -> Result<()> {
163 validate_id(principal_id)?;
164 self.store.with_connection(|connection| {
165 connection.execute(
166 "INSERT OR IGNORE INTO workflow_revoked_grants(principal_id, grant_json)
167 VALUES (?1, ?2)",
168 params![principal_id, serde_json::to_string(grant)?],
169 )?;
170 Ok(())
171 })
172 }
173}
174
175fn storage_conflict(error: anyhow::Error) -> AdmissionConflict {
176 AdmissionConflict::Storage {
177 reason: error.to_string(),
178 }
179}
180
181impl ControlledStore for DurableControlledStore {
182 fn get_graph_revision(&self, graph_id: &str) -> Result<Option<u64>, AdmissionConflict> {
183 self.store
184 .with_connection(|connection| -> Result<Option<u64>> {
185 Ok(connection
186 .query_row(
187 "SELECT graph_revision FROM workflow_graph_state WHERE graph_id=?1",
188 params![graph_id],
189 |row| row.get::<_, i64>(0),
190 )
191 .optional()?
192 .map(|value| value.max(0) as u64))
193 })
194 .map_err(storage_conflict)
195 }
196
197 fn get_node_control_revision(
198 &self,
199 graph_id: &str,
200 node_id: &str,
201 ) -> Result<Option<u64>, AdmissionConflict> {
202 self.store
203 .with_connection(|connection| -> Result<Option<u64>> {
204 Ok(connection
205 .query_row(
206 "SELECT control_revision FROM workflow_node_state
207 WHERE graph_id=?1 AND node_id=?2",
208 params![graph_id, node_id],
209 |row| row.get::<_, i64>(0),
210 )
211 .optional()?
212 .map(|value| value.max(0) as u64))
213 })
214 .map_err(storage_conflict)
215 }
216
217 fn is_invocation_settled(
218 &self,
219 graph_id: &str,
220 node_id: &str,
221 invocation_id: &str,
222 ) -> Result<bool, AdmissionConflict> {
223 self.store
224 .with_connection(|connection| -> Result<bool> {
225 let count: i64 = connection.query_row(
226 "SELECT COUNT(*) FROM workflow_invocations
227 WHERE graph_id=?1 AND node_id=?2 AND invocation_id=?3 AND settled=1",
228 params![graph_id, node_id, invocation_id],
229 |row| row.get(0),
230 )?;
231 Ok(count != 0)
232 })
233 .map_err(storage_conflict)
234 }
235
236 fn is_stop_requested(&self, graph_id: &str, target: &str) -> Result<bool, AdmissionConflict> {
237 self.store
238 .with_connection(|connection| -> Result<bool> {
239 let count: i64 = connection.query_row(
240 "SELECT COUNT(*) FROM workflow_stop_requests
241 WHERE graph_id=?1 AND target IN (?2, 'graph')",
242 params![graph_id, target],
243 |row| row.get(0),
244 )?;
245 Ok(count != 0)
246 })
247 .map_err(storage_conflict)
248 }
249
250 fn is_pause_negotiating(
251 &self,
252 graph_id: &str,
253 target: &str,
254 ) -> Result<bool, AdmissionConflict> {
255 self.store
256 .with_connection(|connection| -> Result<bool> {
257 let count: i64 = connection.query_row(
258 "SELECT COUNT(*) FROM workflow_pause_requests
259 WHERE graph_id=?1 AND target=?2 AND active=1",
260 params![graph_id, target],
261 |row| row.get(0),
262 )?;
263 Ok(count != 0)
264 })
265 .map_err(storage_conflict)
266 }
267
268 fn is_graph_barrier_active(&self, graph_id: &str) -> Result<bool, AdmissionConflict> {
269 self.store
270 .with_connection(|connection| -> Result<bool> {
271 Ok(connection
272 .query_row(
273 "SELECT barrier_active FROM workflow_graph_state WHERE graph_id=?1",
274 params![graph_id],
275 |row| row.get::<_, i64>(0),
276 )
277 .optional()?
278 .is_some_and(|value| value == 1))
279 })
280 .map_err(storage_conflict)
281 }
282
283 fn get_target_generation(
284 &self,
285 graph_id: &str,
286 target: &str,
287 ) -> Result<Option<u64>, AdmissionConflict> {
288 self.store
289 .with_connection(|connection| -> Result<Option<u64>> {
290 Ok(connection
291 .query_row(
292 "SELECT generation FROM workflow_node_state
293 WHERE graph_id=?1 AND node_id=?2",
294 params![graph_id, target],
295 |row| row.get::<_, i64>(0),
296 )
297 .optional()?
298 .map(|value| value.max(0) as u64))
299 })
300 .map_err(storage_conflict)
301 }
302
303 fn is_grant_revoked(
304 &self,
305 principal_id: &str,
306 grant: &OperationGrant,
307 ) -> Result<bool, AdmissionConflict> {
308 self.store
309 .with_connection(|connection| -> Result<bool> {
310 let grant_json = serde_json::to_string(grant)?;
311 let count: i64 = connection.query_row(
312 "SELECT COUNT(*) FROM workflow_revoked_grants
313 WHERE principal_id=?1 AND grant_json=?2",
314 params![principal_id, grant_json],
315 |row| row.get(0),
316 )?;
317 Ok(count != 0)
318 })
319 .map_err(storage_conflict)
320 }
321
322 fn find_idempotency_record(
323 &self,
324 request_id: &str,
325 ) -> Result<Option<(AdmissionReceipt, String)>, AdmissionConflict> {
326 self.store
327 .with_connection(|connection| -> Result<Option<(AdmissionReceipt, String)>> {
328 let value: Option<(String, String)> = connection
329 .query_row(
330 "SELECT receipt_json, payload_digest FROM workflow_control_admissions
331 WHERE request_id=?1",
332 params![request_id],
333 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
334 )
335 .optional()?;
336 value
337 .map(|(receipt, digest)| Ok((serde_json::from_str(&receipt)?, digest)))
338 .transpose()
339 })
340 .map_err(storage_conflict)
341 }
342
343 fn record_admission(
344 &mut self,
345 receipt: &mut AdmissionReceipt,
346 payload_digest: String,
347 expected_control_revision: Option<u64>,
348 fences: &AdmissionFences,
349 ) -> Result<(), AdmissionConflict> {
350 self.store.with_connection_typed(
351 |connection| -> std::result::Result<(), AdmissionConflict> {
352 let transaction = connection
353 .transaction_with_behavior(TransactionBehavior::Immediate)
354 .map_err(AdmissionConflict::from)?;
355 let existing: Option<(String, String)> = transaction
359 .query_row(
360 "SELECT receipt_json, payload_digest FROM workflow_control_admissions
361 WHERE request_id=?1",
362 params![receipt.request_id],
363 |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
364 )
365 .optional()?;
366 if let Some((receipt_json, existing_digest)) = existing {
367 if existing_digest == payload_digest {
368 *receipt = serde_json::from_str(&receipt_json)?;
369 receipt.is_replay = true;
370 transaction.commit()?;
371 return Ok(());
372 }
373 let existing_receipt: AdmissionReceipt =
374 serde_json::from_str(&receipt_json)?;
375 return Err(AdmissionConflict::IdempotencyMismatch {
376 request_id: receipt.request_id.clone(),
377 existing_author: existing_receipt.author_principal_id,
378 reason: "request_id reused with different command payload".to_string(),
379 });
380 }
381 let mut covering_grants_all_revoked = !fences.covering_grants.is_empty();
386 for grant in &fences.covering_grants {
387 let count: i64 = transaction.query_row(
388 "SELECT COUNT(*) FROM workflow_revoked_grants
389 WHERE principal_id=?1 AND grant_json=?2",
390 params![fences.principal_id, serde_json::to_string(grant)?],
391 |row| row.get(0),
392 )?;
393 if count == 0 {
394 covering_grants_all_revoked = false;
395 break;
396 }
397 }
398 let barrier_active: i64 = transaction
399 .query_row(
400 "SELECT barrier_active FROM workflow_graph_state WHERE graph_id=?1",
401 params![receipt.graph_id],
402 |row| row.get(0),
403 )
404 .optional()?
405 .unwrap_or(0);
406 let stop_hit = |target: &str| -> Result<bool, rusqlite::Error> {
407 let count: i64 = transaction.query_row(
408 "SELECT COUNT(*) FROM workflow_stop_requests
409 WHERE graph_id=?1 AND target IN (?2, 'graph')",
410 params![receipt.graph_id, target],
411 |row| row.get(0),
412 )?;
413 Ok(count != 0)
414 };
415 let stopped_scope = if stop_hit(&fences.target_scope)? {
416 Some(fences.target_scope.clone())
417 } else {
418 match &fences.invocation_scope {
419 Some(key) if stop_hit(key)? => Some(key.clone()),
420 _ => None,
421 }
422 };
423 let invocation_settled = match (&receipt.operation, &receipt.target_node_id) {
424 (ControlOperation::Steer { invocation_id, .. }, Some(node)) => {
425 let count: i64 = transaction.query_row(
426 "SELECT COUNT(*) FROM workflow_invocations
427 WHERE graph_id=?1 AND node_id=?2 AND invocation_id=?3 AND settled=1",
428 params![receipt.graph_id, node, invocation_id],
429 |row| row.get(0),
430 )?;
431 count != 0
432 }
433 _ => false,
434 };
435 let pause_negotiating: i64 = transaction.query_row(
436 "SELECT COUNT(*) FROM workflow_pause_requests
437 WHERE graph_id=?1 AND target=?2 AND active=1",
438 params![receipt.graph_id, fences.target_scope],
439 |row| row.get(0),
440 )?;
441 let graph_revision: i64 = transaction
442 .query_row(
443 "SELECT graph_revision FROM workflow_graph_state WHERE graph_id=?1",
444 params![receipt.graph_id],
445 |row| row.get(0),
446 )
447 .optional()?
448 .unwrap_or(1);
449 let target_generation: i64 = transaction
450 .query_row(
451 "SELECT generation FROM workflow_node_state
452 WHERE graph_id=?1 AND node_id=?2",
453 params![receipt.graph_id, fences.target_scope],
454 |row| row.get(0),
455 )
456 .optional()?
457 .unwrap_or(1);
458 evaluate_fences(
459 receipt,
460 fences,
461 FenceFacts {
462 covering_grants_all_revoked,
463 barrier_active: barrier_active == 1,
464 stopped_scope,
465 invocation_settled,
466 pause_negotiating: pause_negotiating != 0,
467 graph_revision: graph_revision.max(0) as u64,
468 target_generation: target_generation.max(0) as u64,
469 },
470 )?;
471 receipt.graph_revision = graph_revision.max(0) as u64;
472 if let Some(node) = receipt.target_node_id.as_deref() {
477 let persisted: i64 = transaction
478 .query_row(
479 "SELECT control_revision FROM workflow_node_state
480 WHERE graph_id=?1 AND node_id=?2",
481 params![receipt.graph_id, node],
482 |row| row.get(0),
483 )
484 .optional()?
485 .unwrap_or(0);
486 let persisted = persisted.max(0) as u64;
487 if let Some(expected) = expected_control_revision
488 && expected != persisted
489 {
490 return Err(AdmissionConflict::ControlRevisionConflict {
491 target_node_id: node.to_string(),
492 expected,
493 current: persisted,
494 reason: "competing exclusive control revision conflict".to_string(),
495 });
496 }
497 receipt.control_revision = persisted + 1;
498 }
499 let next_sequence: i64 = transaction.query_row(
500 "SELECT COALESCE(MAX(admitted_sequence), 0) + 1
501 FROM workflow_control_admissions",
502 [],
503 |row| row.get(0),
504 )?;
505 receipt.admitted_sequence = next_sequence.max(0) as u64;
506 transaction.execute(
507 "INSERT INTO workflow_control_admissions(
508 request_id, admitted_sequence, receipt_json, payload_digest
509 ) VALUES (?1, ?2, ?3, ?4)",
510 params![
511 receipt.request_id,
512 receipt.admitted_sequence as i64,
513 serde_json::to_string(receipt)?,
514 payload_digest,
515 ],
516 )?;
517 transaction.execute(
518 "INSERT INTO workflow_graph_state(graph_id, graph_revision, barrier_active)
519 VALUES (?1, ?2, 0)
520 ON CONFLICT(graph_id) DO NOTHING",
521 params![receipt.graph_id, receipt.graph_revision as i64],
522 )?;
523 if let Some(node) = receipt.target_node_id.as_deref() {
524 transaction.execute(
525 "INSERT INTO workflow_node_state(
526 graph_id, node_id, control_revision, generation, lifecycle_state,
527 pause_negotiating
528 ) VALUES (?1, ?2, ?3, 1, 'ready', 0)
529 ON CONFLICT(graph_id, node_id)
530 DO UPDATE SET control_revision=excluded.control_revision",
531 params![receipt.graph_id, node, receipt.control_revision as i64],
532 )?;
533 }
534 match &receipt.operation {
535 ControlOperation::Pause { scope } => {
536 transaction.execute(
537 "INSERT INTO workflow_pause_requests(graph_id, target, active)
538 VALUES (?1, ?2, 1)
539 ON CONFLICT(graph_id, target) DO UPDATE SET active=1",
540 params![receipt.graph_id, pause_target(scope)],
541 )?;
542 }
543 ControlOperation::Stop { scope, .. } => {
544 let target = control_target(scope);
545 transaction.execute(
546 "INSERT OR IGNORE INTO workflow_stop_requests(graph_id, target)
547 VALUES (?1, ?2)",
548 params![receipt.graph_id, target],
549 )?;
550 if matches!(scope, ControlScope::Graph) {
551 transaction.execute(
552 "UPDATE workflow_graph_state SET barrier_active=1 WHERE graph_id=?1",
553 params![receipt.graph_id],
554 )?;
555 }
556 }
557 _ => {}
558 }
559 let payload = serde_json::to_value(&*receipt)?;
560 queue::enqueue_in_transaction(
561 &transaction,
562 ChannelKind::Control,
563 &receipt.request_id,
564 &payload,
565 receipt.admitted_at_unix_ms,
566 QueueBounds::default(),
567 )
568 .map_err(|error| match error {
569 QueueStoreError::Capacity(capacity) => {
570 AdmissionConflict::QueueCapacity(capacity)
571 }
572 other => AdmissionConflict::Storage {
573 reason: other.to_string(),
574 },
575 })?;
576 transaction.commit()?;
577 Ok(())
578 },
579 )
580 }
581}
582
583pub(crate) fn initialize_schema(connection: &rusqlite::Connection) -> Result<()> {
584 connection.execute_batch(
585 "CREATE TABLE IF NOT EXISTS workflow_graph_state(
586 graph_id TEXT PRIMARY KEY,
587 graph_revision INTEGER NOT NULL,
588 barrier_active INTEGER NOT NULL
589 );
590 CREATE TABLE IF NOT EXISTS workflow_node_state(
591 graph_id TEXT NOT NULL,
592 node_id TEXT NOT NULL,
593 control_revision INTEGER NOT NULL,
594 generation INTEGER NOT NULL,
595 lifecycle_state TEXT NOT NULL,
596 pause_negotiating INTEGER NOT NULL,
597 PRIMARY KEY(graph_id, node_id)
598 );
599 CREATE TABLE IF NOT EXISTS workflow_invocations(
600 graph_id TEXT NOT NULL,
601 node_id TEXT NOT NULL,
602 invocation_id TEXT NOT NULL,
603 settled INTEGER NOT NULL,
604 PRIMARY KEY(graph_id, node_id, invocation_id)
605 );
606 CREATE TABLE IF NOT EXISTS workflow_stop_requests(
607 graph_id TEXT NOT NULL,
608 target TEXT NOT NULL,
609 PRIMARY KEY(graph_id, target)
610 );
611 CREATE TABLE IF NOT EXISTS workflow_pause_requests(
612 graph_id TEXT NOT NULL,
613 target TEXT NOT NULL,
614 active INTEGER NOT NULL,
615 PRIMARY KEY(graph_id, target)
616 );
617 CREATE TABLE IF NOT EXISTS workflow_revoked_grants(
618 principal_id TEXT NOT NULL,
619 grant_json TEXT NOT NULL,
620 PRIMARY KEY(principal_id, grant_json)
621 );
622 CREATE TABLE IF NOT EXISTS workflow_control_admissions(
623 request_id TEXT PRIMARY KEY,
624 admitted_sequence INTEGER NOT NULL UNIQUE,
625 receipt_json TEXT NOT NULL,
626 payload_digest TEXT NOT NULL
627 );",
628 )?;
629 Ok(())
630}
631
632fn control_target(scope: &ControlScope) -> String {
633 match scope {
634 ControlScope::Graph => "graph".to_owned(),
635 ControlScope::Node(node) => node.clone(),
636 ControlScope::Invocation {
637 node_id,
638 invocation_id,
639 } => format!("{node_id}/{invocation_id}"),
640 }
641}
642
643fn pause_target(scope: &ControlScope) -> String {
644 match scope {
645 ControlScope::Graph => "graph".to_owned(),
646 ControlScope::Node(node) => node.clone(),
647 ControlScope::Invocation { node_id, .. } => node_id.clone(),
648 }
649}
650
651fn validate_id(value: &str) -> Result<()> {
652 ensure!(
653 !value.trim().is_empty()
654 && value == value.trim()
655 && value.len() <= 160
656 && !value.chars().any(char::is_control),
657 "workflow_control_id_invalid"
658 );
659 Ok(())
660}
661
662#[cfg(test)]
663mod tests {
664 use super::*;
665 use crate::control::ControlledStore;
666 use std::fs;
667 use uuid::Uuid;
668
669 #[test]
670 fn controlled_state_and_admission_receipts_survive_restart() {
671 let root = std::env::temp_dir().join(format!("lico-workflow-control-{}", Uuid::new_v4()));
672 let grant = OperationGrant::GraphAll;
673 {
674 let store = StrategyStore::open(&root).unwrap();
675 let mut control = store.durable_control();
676 control.set_graph_revision("graph-1", 4).unwrap();
677 control
678 .set_node_control_revision("graph-1", "node-1", 9)
679 .unwrap();
680 control
681 .set_target_generation("graph-1", "node-1", 3)
682 .unwrap();
683 control
684 .mark_invocation_settled("graph-1", "node-1", "inv-1")
685 .unwrap();
686 control.mark_stop_requested("graph-1", "node-1").unwrap();
687 control.mark_pause_negotiating("graph-1", "node-1").unwrap();
688 control.revoke_grant("principal-1", &grant).unwrap();
689
690 let mut receipt = AdmissionReceipt {
691 admitted_sequence: 0,
692 request_id: "request-1".into(),
693 graph_id: "graph-1".into(),
694 target_node_id: Some("node-1".into()),
695 author_principal_id: "principal-1".into(),
696 admitted_at_unix_ms: 10,
697 operation: ControlOperation::Pause {
698 scope: ControlScope::Invocation {
699 node_id: "node-1".into(),
700 invocation_id: "inv-1".into(),
701 },
702 },
703 graph_revision: 4,
704 control_revision: 9,
705 is_replay: false,
706 };
707 control
708 .record_admission(
709 &mut receipt,
710 "digest".into(),
711 Some(9),
712 &AdmissionFences::default(),
713 )
714 .unwrap();
715 assert_eq!(receipt.admitted_sequence, 1);
716 }
717
718 let store = StrategyStore::open(&root).unwrap();
719 let control = store.durable_control();
720 let queued = store.durable_queue().replay_from_cursor(0).unwrap();
721 assert_eq!(queued.len(), 1);
722 assert_eq!(queued[0].channel, ChannelKind::Control);
723 assert_eq!(queued[0].item_id, "request-1");
724 assert_eq!(control.get_graph_revision("graph-1").unwrap(), Some(4));
725 assert_eq!(
726 control
727 .get_node_control_revision("graph-1", "node-1")
728 .unwrap(),
729 Some(10)
730 );
731 assert_eq!(
732 control.get_target_generation("graph-1", "node-1").unwrap(),
733 Some(3)
734 );
735 assert!(
736 control
737 .is_invocation_settled("graph-1", "node-1", "inv-1")
738 .unwrap()
739 );
740 assert!(control.is_stop_requested("graph-1", "node-1").unwrap());
741 assert!(control.is_pause_negotiating("graph-1", "node-1").unwrap());
742 assert!(control.is_grant_revoked("principal-1", &grant).unwrap());
743 assert_eq!(
744 control
745 .find_idempotency_record("request-1")
746 .unwrap()
747 .unwrap()
748 .0
749 .admitted_sequence,
750 1
751 );
752 assert!(
753 control
754 .clear_pause_negotiating("graph-1", "node-1")
755 .unwrap()
756 );
757 assert!(!control.is_pause_negotiating("graph-1", "node-1").unwrap());
758 drop(control);
759 drop(store);
760 let _ = fs::remove_dir_all(root);
761 }
762}