1use std::collections::{BTreeMap, VecDeque};
11use std::sync::{Arc, Mutex, MutexGuard};
12
13use async_trait::async_trait;
14use pointlock_ir::{
15 ActionName, ActionOutcome, ActionResult, AssetRef, ErrorClass, ErrorInfo, EventCursor,
16 FeatureId, Hash, JsonSchemaDocument, Observation, ReconcileResult, ScreenshotOmissionReason,
17 UiSnapshotOmissionReason, UiSnapshotRef, Viewport,
18};
19use serde_json::json;
20use sha2::{Digest, Sha256};
21
22use crate::error::{ProviderError, RetryableSource};
23use crate::lockfile::{
24 CapabilityAttestation, CapabilityLockfile, LockfileDevice, LockfileHello, LockfileProvider,
25 PeerInfo, ProtocolVersion,
26};
27use crate::manifest::{
28 ActionDefinitionStatic, ActionProtection, ChannelRole, ChannelSupport, FeatureDeclarations,
29 PlatformKind, ProtocolRange, ProviderManifest, VerbBinding,
30};
31use crate::spi::{
32 BoundActionCall, CancellationToken, EvidenceStream, ObserveRequest, ObserveWant, Provider,
33 ProviderSession, SessionHealth, SessionOutcome, UiSnapshotOutcome,
34 VERDICT_EVIDENCE_MAX_ENTRIES, VERDICT_SUMMARY_MAX_CHARS, VerdictWrite,
35};
36
37#[derive(Debug, Clone, PartialEq)]
41pub enum ScriptedOutcome {
42 Terminal(ActionOutcome),
48 TransportLostAfterDispatch,
53 TransportLostBeforeDispatch,
57}
58
59impl ScriptedOutcome {
60 pub fn succeeded() -> Self {
63 ScriptedOutcome::Terminal(ActionOutcome::Succeeded {
64 result: Box::new(ActionResult {
65 call_id: String::new(),
66 started_at_ms: 0,
67 finished_at_ms: 0,
68 output: json!({}),
69 before: None,
70 after: None,
71 evidence: Vec::new(),
72 execution: None,
73 }),
74 })
75 }
76
77 pub fn failed(code: &str, retryable: bool) -> Self {
79 ScriptedOutcome::Terminal(ActionOutcome::Failed {
80 error: ErrorInfo {
81 code: code.to_owned(),
82 message: format!("scripted failure: {code}"),
83 retryable,
84 details: None,
85 },
86 })
87 }
88
89 pub fn cancelled() -> Self {
91 ScriptedOutcome::Terminal(ActionOutcome::Cancelled {
92 error: ErrorInfo {
93 code: "action_cancelled".to_owned(),
94 message: "scripted cancellation".to_owned(),
95 retryable: false,
96 details: None,
97 },
98 })
99 }
100
101 pub fn timed_out() -> Self {
103 ScriptedOutcome::Terminal(ActionOutcome::TimedOut {
104 error: ErrorInfo {
105 code: "action_timeout".to_owned(),
106 message: "scripted action budget expiry".to_owned(),
107 retryable: true,
108 details: None,
109 },
110 })
111 }
112}
113
114#[derive(Debug, Clone, PartialEq)]
117struct JournalEntry {
118 call_id: String,
119 terminal: Option<ActionOutcome>,
122}
123
124#[derive(Debug)]
125struct FakeWorld {
126 script: VecDeque<ScriptedOutcome>,
127 journal: Vec<JournalEntry>,
128 verdicts: Vec<VerdictWrite>,
129 sequence: u64,
131 clock_ms: u64,
133 observation_counter: u64,
134 session_counter: u64,
135 screenshot_omission: Option<ScreenshotOmissionReason>,
136 ui_snapshot_omission: Option<UiSnapshotOmissionReason>,
137 log_unavailable: Option<String>,
140 fetch_unsupported: Option<String>,
144 record_verdict_error: Option<String>,
148 evidence_store: BTreeMap<String, Vec<u8>>,
150 injected_ui_snapshot: Option<serde_json::Value>,
153 ui_snapshots: BTreeMap<String, serde_json::Value>,
155 ended: Option<(SessionOutcome, Option<String>)>,
156}
157
158impl FakeWorld {
159 fn new(script: VecDeque<ScriptedOutcome>) -> Self {
160 FakeWorld {
161 script,
162 journal: Vec::new(),
163 verdicts: Vec::new(),
164 sequence: 0,
165 clock_ms: 0,
166 observation_counter: 0,
167 session_counter: 0,
168 screenshot_omission: None,
169 ui_snapshot_omission: None,
170 log_unavailable: None,
171 fetch_unsupported: None,
172 record_verdict_error: None,
173 evidence_store: BTreeMap::new(),
174 injected_ui_snapshot: None,
175 ui_snapshots: BTreeMap::new(),
176 ended: None,
177 }
178 }
179
180 fn tick_ms(&mut self) -> u64 {
181 self.clock_ms += 1;
182 self.clock_ms
183 }
184
185 fn bump_sequence(&mut self) {
186 self.sequence += 1;
187 }
188
189 fn make_asset(&mut self, id: String, media_type: &str, bytes: Vec<u8>) -> AssetRef {
192 let sha256 = hex_sha256(&bytes);
193 self.evidence_store.insert(id.clone(), bytes);
194 AssetRef {
195 uri: format!("fake://assets/{id}"),
196 id,
197 media_type: media_type.to_owned(),
198 sha256: Some(sha256),
199 }
200 }
201
202 fn synthesize_observation(
208 &mut self,
209 device_id: &str,
210 ui_snapshot: Option<serde_json::Value>,
211 ) -> Observation {
212 self.observation_counter += 1;
213 let observation_id = format!("obs-{}", self.observation_counter);
214 let captured_at_ms = self.tick_ms();
215 let screenshot = {
216 let id = format!("{observation_id}-screenshot");
217 let bytes = format!("fake-screenshot:{observation_id}").into_bytes();
218 self.make_asset(id, "image/png", bytes)
219 };
220 let ui_snapshot = ui_snapshot.map(|snapshot| {
221 let bytes =
222 serde_json::to_vec(&snapshot).expect("an injected UiSnapshot value serializes");
223 let id = format!("{observation_id}-uitree");
224 let evidence = self.make_asset(id, "application/json", bytes);
225 self.ui_snapshots.insert(observation_id.clone(), snapshot);
226 UiSnapshotRef { evidence }
227 });
228 Observation {
229 id: observation_id,
230 device_id: device_id.to_owned(),
231 captured_at_ms,
232 viewport: Viewport {
233 width: 1080,
234 height: 2400,
235 scale_factor: 2.0,
236 },
237 screenshot: Some(screenshot),
238 screenshot_omission: None,
239 ui_snapshot,
240 ui_snapshot_omission: None,
241 metadata: BTreeMap::new(),
242 }
243 }
244}
245
246#[derive(Debug, Clone)]
249pub struct FakeHandle {
250 world: Arc<Mutex<FakeWorld>>,
251}
252
253impl FakeHandle {
254 fn world(&self) -> MutexGuard<'_, FakeWorld> {
255 self.world.lock().expect("fake world lock poisoned")
256 }
257
258 pub fn push_script(&self, outcome: ScriptedOutcome) {
260 self.world().script.push_back(outcome);
261 }
262
263 pub fn set_log_unavailable(&self, reason: impl Into<String>) {
265 self.world().log_unavailable = Some(reason.into());
266 }
267
268 pub fn clear_log_unavailable(&self) {
270 self.world().log_unavailable = None;
271 }
272
273 pub fn set_fetch_evidence_unsupported(&self, reason: Option<String>) {
277 self.world().fetch_unsupported = reason;
278 }
279
280 pub fn set_record_verdict_error(&self, reason: Option<String>) {
284 self.world().record_verdict_error = reason;
285 }
286
287 pub fn set_screenshot_omission(&self, reason: Option<ScreenshotOmissionReason>) {
289 self.world().screenshot_omission = reason;
290 }
291
292 pub fn set_ui_snapshot_omission(&self, reason: Option<UiSnapshotOmissionReason>) {
295 self.world().ui_snapshot_omission = reason;
296 }
297
298 pub fn insert_evidence(&self, asset_id: impl Into<String>, bytes: Vec<u8>) {
300 self.world().evidence_store.insert(asset_id.into(), bytes);
301 }
302
303 pub fn inject_ui_snapshot(&self, snapshot: Option<serde_json::Value>) {
309 self.world().injected_ui_snapshot = snapshot;
310 }
311
312 pub fn make_observation(&self, ui_snapshot: Option<serde_json::Value>) -> Observation {
319 self.make_observation_for("fake-device-1", ui_snapshot)
320 }
321
322 pub fn make_observation_for(
326 &self,
327 device_id: &str,
328 ui_snapshot: Option<serde_json::Value>,
329 ) -> Observation {
330 self.world().synthesize_observation(device_id, ui_snapshot)
331 }
332
333 pub fn recorded_verdicts(&self) -> Vec<VerdictWrite> {
335 self.world().verdicts.clone()
336 }
337
338 pub fn dispatched_call_ids(&self) -> Vec<String> {
340 self.world()
341 .journal
342 .iter()
343 .map(|entry| entry.call_id.clone())
344 .collect()
345 }
346
347 pub fn ended_outcome(&self) -> Option<(SessionOutcome, Option<String>)> {
349 self.world().ended.clone()
350 }
351}
352
353#[derive(Debug)]
360pub struct FakeProvider {
361 manifest: ProviderManifest,
362 lockfile: CapabilityLockfile,
363 world: Arc<Mutex<FakeWorld>>,
364}
365
366impl FakeProvider {
367 pub fn new(script: VecDeque<ScriptedOutcome>) -> Self {
370 let manifest = Self::default_manifest();
371 let lockfile = Self::default_lockfile(&manifest);
372 FakeProvider {
373 manifest,
374 lockfile,
375 world: Arc::new(Mutex::new(FakeWorld::new(script))),
376 }
377 }
378
379 pub fn handle(&self) -> FakeHandle {
381 FakeHandle {
382 world: Arc::clone(&self.world),
383 }
384 }
385
386 pub fn lockfile(&self) -> &CapabilityLockfile {
389 &self.lockfile
390 }
391
392 pub fn default_open_options(&self) -> crate::spi::OpenSessionOptions {
394 crate::spi::OpenSessionOptions {
395 endpoint: json!({ "fake": true }),
396 device_id: "fake-device-1".to_owned(),
397 required_features: self.lockfile.hello.features_enabled.clone(),
398 lockfile_digest: self.lockfile.digest.clone(),
399 }
400 }
401
402 fn feature(id: &str) -> FeatureId {
403 FeatureId::new(id).expect("built-in feature ids are grammatical")
404 }
405
406 fn action_name(name: &str) -> ActionName {
407 ActionName::new(name).expect("built-in action names are grammatical")
408 }
409
410 fn semantic_action(name: &str) -> ActionDefinitionStatic {
411 ActionDefinitionStatic {
412 name: Self::action_name(name),
413 input_schema: JsonSchemaDocument::new(json!({ "type": "object" }))
414 .expect("an object schema is a valid schema document"),
415 output_schema: None,
416 protection: ActionProtection::Standard,
417 synthetic: false,
418 }
419 }
420
421 fn synthetic_action(name: &str) -> ActionDefinitionStatic {
425 let input_schema = if name == "observe" {
429 json!({
430 "type": "object",
431 "additionalProperties": false,
432 "required": ["wants"],
433 "properties": {
434 "wants": {
435 "type": "array",
436 "minItems": 1,
437 "uniqueItems": true,
438 "items": { "enum": ["screenshot", "uiSnapshot"] }
439 }
440 }
441 })
442 } else {
443 json!({ "type": "object", "additionalProperties": false, "properties": {} })
444 };
445 ActionDefinitionStatic {
446 name: Self::action_name(name),
447 input_schema: JsonSchemaDocument::new(input_schema)
448 .expect("an object schema is a valid schema document"),
449 output_schema: None,
450 protection: ActionProtection::Standard,
451 synthetic: true,
452 }
453 }
454
455 fn default_manifest() -> ProviderManifest {
456 use pointlock_ir::CanonicalVerb;
457 let semantic = Self::feature("device.semanticActions.v1");
458 let verb_binding = |verb: CanonicalVerb, action: &str, arg: &str| VerbBinding {
459 verb,
460 action_name: Self::action_name(action),
461 requires_feature: Some(semantic.clone()),
462 arg_map: BTreeMap::from([(arg.to_owned(), arg.to_owned())]),
463 };
464 ProviderManifest {
465 name: "fake".to_owned(),
466 version: "0.1.0".to_owned(),
467 protocol: ProtocolRange {
468 major: 1,
469 min_minor: 5,
470 max_minor: 5,
471 },
472 features: FeatureDeclarations {
473 guaranteed: vec![
474 Self::feature("device.semanticActions.v1"),
475 Self::feature("observation.uiSnapshot.v1"),
476 Self::feature("verdict.record.v1"),
477 Self::feature("events.snapshot.v1"),
478 Self::feature("request.control.v1"),
479 ],
480 conditional: Vec::new(),
481 },
482 verb_bindings: vec![
483 verb_binding(CanonicalVerb::Tap, "tapElement", "element"),
484 verb_binding(CanonicalVerb::SetValue, "setElementValue", "element"),
485 verb_binding(CanonicalVerb::Clear, "clearElement", "element"),
486 verb_binding(CanonicalVerb::WaitFor, "waitForElement", "element"),
487 verb_binding(CanonicalVerb::Find, "findElement", "element"),
488 verb_binding(CanonicalVerb::Observe, "observe", "wants"),
489 VerbBinding {
494 verb: CanonicalVerb::Screenshot,
495 action_name: Self::action_name("screenshot"),
496 requires_feature: None,
497 arg_map: BTreeMap::new(),
498 },
499 ],
500 channels: vec![
501 ChannelSupport {
502 channel: pointlock_ir::Channel::UiTree,
503 role: ChannelRole::Both,
504 requires_feature: Some(Self::feature("observation.uiSnapshot.v1")),
505 requires_platform: None,
506 },
507 ChannelSupport {
512 channel: pointlock_ir::Channel::Vision,
513 role: ChannelRole::Verify,
514 requires_feature: None,
515 requires_platform: None,
516 },
517 ],
518 known_actions: vec![
519 Self::semantic_action("findElement"),
520 Self::semantic_action("tapElement"),
521 Self::semantic_action("clearElement"),
522 Self::semantic_action("setElementValue"),
523 Self::semantic_action("waitForElement"),
524 Self::synthetic_action("observe"),
525 Self::synthetic_action("screenshot"),
526 ],
527 }
528 }
529
530 fn default_lockfile(manifest: &ProviderManifest) -> CapabilityLockfile {
531 let mut lockfile = CapabilityLockfile {
532 provider: LockfileProvider {
533 name: manifest.name.clone(),
534 version: manifest.version.clone(),
535 },
536 attested_at: "1970-01-01T00:00:00Z".to_owned(),
537 hello: LockfileHello {
538 protocol_selected: ProtocolVersion { major: 1, minor: 5 },
539 features_enabled: manifest.features.guaranteed.clone(),
540 server: PeerInfo {
541 name: "fake-daemon".to_owned(),
542 version: manifest.version.clone(),
543 },
544 },
545 device: LockfileDevice {
546 platform: PlatformKind::Android,
547 actions: manifest
550 .known_actions
551 .iter()
552 .filter(|action| !action.synthetic)
553 .cloned()
554 .collect(),
555 },
556 digest: Hash::new(format!("sha256:{}", "0".repeat(64)))
557 .expect("the placeholder digest is grammatical"),
558 };
559 lockfile.seal();
560 lockfile
561 }
562}
563
564#[async_trait]
565impl Provider for FakeProvider {
566 fn manifest(&self) -> &ProviderManifest {
567 &self.manifest
568 }
569
570 async fn open_session(
571 &self,
572 opts: crate::spi::OpenSessionOptions,
573 ) -> Result<Box<dyn ProviderSession>, ProviderError> {
574 if opts.lockfile_digest != self.lockfile.digest {
575 return Err(ProviderError::new(
576 ErrorClass::CapabilityDrift,
577 format!(
578 "attestation mismatch: live world digest {} != expected lockfileDigest {}",
579 self.lockfile.digest, opts.lockfile_digest
580 ),
581 RetryableSource::Classifier,
582 ));
583 }
584 if let Some(missing) = opts
585 .required_features
586 .iter()
587 .find(|feature| !self.lockfile.hello.features_enabled.contains(feature))
588 {
589 return Err(ProviderError::new(
590 ErrorClass::CapabilityDrift,
591 format!("required feature not negotiated: {missing}"),
592 RetryableSource::Classifier,
593 ));
594 }
595 let session_id = {
596 let mut world = self.world.lock().expect("fake world lock poisoned");
597 world.session_counter += 1;
598 world.ended = None;
600 format!("fake-session-{}", world.session_counter)
601 };
602 let attestation = CapabilityAttestation::from_lockfile(
603 &self.lockfile,
604 "1970-01-01T00:00:01Z",
606 );
607 Ok(Box::new(FakeProviderSession {
608 session_id,
609 device_id: opts.device_id,
610 attestation,
611 world: Arc::clone(&self.world),
612 }))
613 }
614}
615
616#[derive(Debug)]
618pub struct FakeProviderSession {
619 session_id: String,
620 device_id: String,
621 attestation: CapabilityAttestation,
622 world: Arc<Mutex<FakeWorld>>,
623}
624
625impl FakeProviderSession {
626 fn world(&self) -> MutexGuard<'_, FakeWorld> {
627 self.world.lock().expect("fake world lock poisoned")
628 }
629
630 fn transport_lost(context: &str) -> ProviderError {
631 ProviderError::new(
632 ErrorClass::TransportLost,
633 format!("fake transport lost: {context}"),
634 RetryableSource::Classifier,
635 )
636 .with_client_code("transport_closed")
637 }
638
639 fn ensure_active(world: &FakeWorld, method: &str) -> Result<(), ProviderError> {
640 match world.ended {
641 Some(_) => Err(Self::transport_lost(&format!(
642 "session already ended; {method} unavailable"
643 ))),
644 None => Ok(()),
645 }
646 }
647
648 fn pre_cancelled(cancel: &Option<CancellationToken>) -> bool {
649 cancel.as_ref().is_some_and(CancellationToken::is_cancelled)
650 }
651
652 fn cancelled_before_dispatch() -> ProviderError {
653 ProviderError::new(
654 ErrorClass::ActionCancelled,
655 "cancellation token was already cancelled; no wire request was sent",
656 RetryableSource::Classifier,
657 )
658 }
659}
660
661fn hex_sha256(bytes: &[u8]) -> String {
662 let digest = Sha256::digest(bytes);
663 digest.iter().map(|byte| format!("{byte:02x}")).collect()
664}
665
666#[async_trait]
667impl ProviderSession for FakeProviderSession {
668 fn attestation(&self) -> &CapabilityAttestation {
669 &self.attestation
670 }
671
672 async fn execute(
673 &self,
674 call: BoundActionCall,
675 cancel: Option<CancellationToken>,
676 ) -> Result<ActionOutcome, ProviderError> {
677 if !self.attestation.actions.contains_key(&call.action_name)
683 && let Some(wants) = crate::synthetic_observation_wants(&call)?
684 {
685 {
686 let world = self.world();
687 Self::ensure_active(&world, "execute")?;
688 }
689 if Self::pre_cancelled(&cancel) {
690 return Err(Self::cancelled_before_dispatch());
691 }
692 let started_at_ms = crate::now_ms();
693 let observation = self
694 .observe(
695 ObserveRequest {
696 wants: wants.clone(),
697 },
698 cancel,
699 )
700 .await?;
701 return Ok(ActionOutcome::Succeeded {
702 result: Box::new(pointlock_ir::ActionResult {
703 call_id: call.call_id,
704 started_at_ms,
705 finished_at_ms: crate::now_ms(),
706 output: crate::observation_projection(&observation, &wants),
707 before: None,
708 after: Some(observation),
709 evidence: Vec::new(),
710 execution: None,
711 }),
712 });
713 }
714 let mut world = self.world();
715 Self::ensure_active(&world, "execute")?;
716 if Self::pre_cancelled(&cancel) {
717 return Err(Self::cancelled_before_dispatch());
718 }
719 if !self.attestation.actions.contains_key(&call.action_name) {
720 return Err(ProviderError::new(
721 ErrorClass::CapabilityDrift,
722 format!(
723 "actionName {} is not attested; refusing to dispatch",
724 call.action_name
725 ),
726 RetryableSource::Classifier,
727 ));
728 }
729 if world
730 .journal
731 .iter()
732 .any(|entry| entry.call_id == call.call_id)
733 {
734 return Err(ProviderError::new(
735 ErrorClass::BindArgumentsInvalid,
736 format!(
737 "duplicate callId {}: a retry must be a new callId with a new WAL intent",
738 call.call_id
739 ),
740 RetryableSource::Classifier,
741 ));
742 }
743
744 let scripted = world.script.pop_front().unwrap_or_else(|| {
745 ScriptedOutcome::Terminal(ActionOutcome::Failed {
748 error: ErrorInfo {
749 code: "fake_script_exhausted".to_owned(),
750 message: "FakeProvider script exhausted; push more ScriptedOutcomes".to_owned(),
751 retryable: false,
752 details: None,
753 },
754 })
755 });
756
757 match scripted {
758 ScriptedOutcome::Terminal(mut outcome) => {
759 let started_at_ms = world.tick_ms();
760 let finished_at_ms = world.tick_ms();
761 if let ActionOutcome::Succeeded { result } = &mut outcome {
762 result.call_id = call.call_id.clone();
763 result.started_at_ms = started_at_ms;
764 result.finished_at_ms = finished_at_ms;
765 }
766 world.bump_sequence();
768 world.bump_sequence();
769 world.journal.push(JournalEntry {
770 call_id: call.call_id,
771 terminal: Some(outcome.clone()),
772 });
773 Ok(outcome)
774 }
775 ScriptedOutcome::TransportLostAfterDispatch => {
776 world.bump_sequence();
778 world.journal.push(JournalEntry {
779 call_id: call.call_id,
780 terminal: None,
781 });
782 Err(Self::transport_lost("connection dropped after dispatch"))
783 }
784 ScriptedOutcome::TransportLostBeforeDispatch => {
785 Err(Self::transport_lost("connection dropped before dispatch"))
786 }
787 }
788 }
789
790 async fn observe(
791 &self,
792 req: ObserveRequest,
793 cancel: Option<CancellationToken>,
794 ) -> Result<Observation, ProviderError> {
795 let mut world = self.world();
796 Self::ensure_active(&world, "observe")?;
797 if Self::pre_cancelled(&cancel) {
798 return Err(Self::cancelled_before_dispatch());
799 }
800 world.observation_counter += 1;
801 let observation_id = format!("obs-{}", world.observation_counter);
802 let captured_at_ms = world.tick_ms();
803
804 let wants_screenshot = req.wants.contains(&ObserveWant::Screenshot);
805 let wants_ui_snapshot = req.wants.contains(&ObserveWant::UiSnapshot);
806
807 let screenshot_omission = world.screenshot_omission;
808 let screenshot = (wants_screenshot && screenshot_omission.is_none()).then(|| {
809 let id = format!("{observation_id}-screenshot");
810 let bytes = format!("fake-screenshot:{observation_id}").into_bytes();
811 world.make_asset(id, "image/png", bytes)
812 });
813
814 let ui_snapshot_omission = world.ui_snapshot_omission;
815 let ui_snapshot = (wants_ui_snapshot && ui_snapshot_omission.is_none()).then(|| {
816 let id = format!("{observation_id}-uitree");
817 let bytes = match world.injected_ui_snapshot.clone() {
821 Some(snapshot) => {
822 let bytes = serde_json::to_vec(&snapshot)
823 .expect("an injected UiSnapshot value serializes");
824 world.ui_snapshots.insert(observation_id.clone(), snapshot);
825 bytes
826 }
827 None => format!("fake-uitree:{observation_id}").into_bytes(),
828 };
829 UiSnapshotRef {
830 evidence: world.make_asset(id, "application/json", bytes),
831 }
832 });
833
834 world.bump_sequence();
836 Ok(Observation {
837 id: observation_id,
838 device_id: self.device_id.clone(),
839 captured_at_ms,
840 viewport: Viewport {
841 width: 1080,
842 height: 2400,
843 scale_factor: 2.0,
844 },
845 screenshot,
846 screenshot_omission: wants_screenshot.then_some(screenshot_omission).flatten(),
847 ui_snapshot,
848 ui_snapshot_omission: wants_ui_snapshot.then_some(ui_snapshot_omission).flatten(),
849 metadata: BTreeMap::new(),
850 })
851 }
852
853 async fn ui_snapshot(&self, observation_id: &str) -> Result<UiSnapshotOutcome, ProviderError> {
854 let world = self.world();
855 Self::ensure_active(&world, "ui_snapshot")?;
856 if let Some(reason) = world.ui_snapshot_omission {
857 return Ok(UiSnapshotOutcome::Unavailable { reason });
858 }
859 if let Some(snapshot) = world.ui_snapshots.get(observation_id) {
860 return Ok(UiSnapshotOutcome::Available {
861 snapshot: snapshot.clone(),
862 });
863 }
864 Ok(UiSnapshotOutcome::Available {
865 snapshot: json!({
866 "observationId": observation_id,
867 "contexts": [],
868 "nodes": [],
869 }),
870 })
871 }
872
873 async fn reconcile(
874 &self,
875 call_id: &str,
876 issuing: &EventCursor,
877 ) -> Result<ReconcileResult, ProviderError> {
878 let _ = issuing;
885 let world = self.world();
886 if let Some(reason) = &world.log_unavailable {
887 return Ok(ReconcileResult::LogUnavailable {
888 reason: reason.clone(),
889 });
890 }
891 let Some(entry) = world.journal.iter().find(|entry| entry.call_id == call_id) else {
892 return Ok(ReconcileResult::NeverDispatched);
895 };
896 match &entry.terminal {
897 Some(outcome) => Ok(ReconcileResult::Completed {
900 outcome: Box::new(outcome.clone()),
901 }),
902 None => Ok(ReconcileResult::StartedNoTerminal),
903 }
904 }
905
906 async fn fetch_evidence(&self, asset: &AssetRef) -> Result<EvidenceStream, ProviderError> {
907 let world = self.world();
908 Self::ensure_active(&world, "fetch_evidence")?;
909 if let Some(reason) = &world.fetch_unsupported {
910 return Err(ProviderError::new(
911 ErrorClass::ActionFailedFinal,
912 format!("fetch_evidence unsupported: {reason}"),
913 RetryableSource::Classifier,
914 )
915 .with_client_code("asset_fetch_unsupported"));
916 }
917 let Some(bytes) = world.evidence_store.get(&asset.id).cloned() else {
918 return Err(ProviderError::new(
919 ErrorClass::ActionFailedFinal,
920 format!("unknown evidence asset: {}", asset.id),
921 RetryableSource::Classifier,
922 ));
923 };
924 if let Some(expected) = &asset.sha256 {
925 let actual = hex_sha256(&bytes);
926 if &actual != expected {
927 return Err(ProviderError::new(
928 ErrorClass::ActionFailedFinal,
929 format!(
930 "evidence integrity failure for {}: sha256 {actual} != declared {expected}",
931 asset.id
932 ),
933 RetryableSource::Classifier,
934 ));
935 }
936 }
937 Ok(Box::pin(futures_util::stream::iter([Ok(bytes)])))
938 }
939
940 async fn record_verdict(&self, verdict: VerdictWrite) -> Result<(), ProviderError> {
941 let mut world = self.world();
942 Self::ensure_active(&world, "record_verdict")?;
943 if let Some(reason) = &world.record_verdict_error {
944 return Err(ProviderError::new(
945 ErrorClass::TransportLost,
946 format!("scripted verdict.record failure: {reason}"),
947 RetryableSource::Classifier,
948 ));
949 }
950 let summary_chars = verdict.summary.chars().count();
951 if summary_chars > VERDICT_SUMMARY_MAX_CHARS {
952 return Err(ProviderError::new(
953 ErrorClass::BindArgumentsInvalid,
954 format!(
955 "verdict summary is {summary_chars} chars; wire cap is \
956 {VERDICT_SUMMARY_MAX_CHARS} (fail-closed; compaction is the runner's job)"
957 ),
958 RetryableSource::Classifier,
959 ));
960 }
961 if verdict.evidence.len() > VERDICT_EVIDENCE_MAX_ENTRIES {
962 return Err(ProviderError::new(
963 ErrorClass::BindArgumentsInvalid,
964 format!(
965 "verdict cites {} evidence entries; wire cap is {VERDICT_EVIDENCE_MAX_ENTRIES}",
966 verdict.evidence.len()
967 ),
968 RetryableSource::Classifier,
969 ));
970 }
971 world.verdicts.push(verdict);
972 world.bump_sequence();
974 Ok(())
975 }
976
977 async fn current_cursor(&self) -> Result<EventCursor, ProviderError> {
978 let world = self.world();
979 Ok(EventCursor {
980 session_id: self.session_id.clone(),
981 last_sequence: world.sequence,
982 })
983 }
984
985 async fn health(&self) -> Result<SessionHealth, ProviderError> {
986 let world = self.world();
987 Ok(SessionHealth {
988 ok: world.ended.is_none(),
989 degraded: None,
990 })
991 }
992
993 async fn end(
994 &self,
995 outcome: SessionOutcome,
996 reason: Option<String>,
997 ) -> Result<(), ProviderError> {
998 let mut world = self.world();
999 if world.ended.is_some() {
1000 return Ok(());
1002 }
1003 world.ended = Some((outcome, reason));
1004 world.bump_sequence();
1006 Ok(())
1007 }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013 use futures_util::StreamExt;
1014 use pointlock_ir::VerdictStatus;
1015
1016 fn call(call_id: &str) -> BoundActionCall {
1017 BoundActionCall {
1018 call_id: call_id.to_owned(),
1019 action_name: ActionName::new("tapElement").unwrap(),
1020 arguments: json!({}),
1021 action_timeout_ms: None,
1022 request_timeout_ms: None,
1023 }
1024 }
1025
1026 async fn open(provider: &FakeProvider) -> Box<dyn ProviderSession> {
1027 provider
1028 .open_session(provider.default_open_options())
1029 .await
1030 .expect("open_session")
1031 }
1032
1033 #[tokio::test]
1034 async fn execute_stamps_call_id_and_journals_terminal() {
1035 let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1036 let session = open(&provider).await;
1037 let outcome = session
1038 .execute(call("call-1"), None)
1039 .await
1040 .expect("execute");
1041 let ActionOutcome::Succeeded { result } = outcome else {
1042 panic!("expected succeeded, got {outcome:?}");
1043 };
1044 assert_eq!(result.call_id, "call-1");
1045 assert!(result.finished_at_ms > result.started_at_ms);
1046 assert_eq!(provider.handle().dispatched_call_ids(), vec!["call-1"]);
1047
1048 let fate = session
1049 .reconcile("call-1", &session.current_cursor().await.expect("cursor"))
1050 .await
1051 .expect("reconcile");
1052 let ReconcileResult::Completed { outcome } = fate else {
1053 panic!("expected completed, got {fate:?}");
1054 };
1055 let ActionOutcome::Succeeded { result } = *outcome else {
1056 panic!("expected a succeeded terminal, got {outcome:?}");
1057 };
1058 assert_eq!(result.call_id, "call-1");
1059 }
1060
1061 #[tokio::test]
1062 async fn a_foreign_issuing_credential_reads_the_world_journal() {
1063 let provider = FakeProvider::new(VecDeque::new());
1070 let session = open(&provider).await;
1071 let foreign = EventCursor {
1072 session_id: "some-earlier-generation".to_owned(),
1073 last_sequence: 3,
1074 };
1075 let fate = session
1076 .reconcile("never-seen", &foreign)
1077 .await
1078 .expect("reconcile");
1079 assert!(
1080 matches!(fate, ReconcileResult::NeverDispatched),
1081 "got {fate:?}"
1082 );
1083 }
1084
1085 #[tokio::test]
1086 async fn reconcile_adopts_archived_non_succeeded_terminals_verbatim() {
1087 let provider = FakeProvider::new(VecDeque::from([
1088 ScriptedOutcome::failed("device_unavailable", true),
1089 ScriptedOutcome::timed_out(),
1090 ]));
1091 let session = open(&provider).await;
1092 session
1094 .execute(call("call-f"), None)
1095 .await
1096 .expect("failed terminal");
1097 session
1098 .execute(call("call-t"), None)
1099 .await
1100 .expect("timedOut terminal");
1101
1102 for (call_id, kind) in [("call-f", "failed"), ("call-t", "timedOut")] {
1103 let fate = session
1104 .reconcile(call_id, &session.current_cursor().await.expect("cursor"))
1105 .await
1106 .expect("reconcile");
1107 let ReconcileResult::Completed { outcome } = fate else {
1108 panic!("expected completed for {call_id}, got {fate:?}");
1109 };
1110 assert_eq!(outcome.kind(), kind);
1111 }
1112 }
1113
1114 #[tokio::test]
1115 async fn transport_loss_variants_map_to_reconcile_fates() {
1116 let provider = FakeProvider::new(VecDeque::from([
1117 ScriptedOutcome::TransportLostAfterDispatch,
1118 ScriptedOutcome::TransportLostBeforeDispatch,
1119 ]));
1120 let session = open(&provider).await;
1121
1122 let error = session.execute(call("hung"), None).await.unwrap_err();
1123 assert_eq!(error.error_class, ErrorClass::TransportLost);
1124 assert_eq!(
1125 session
1126 .reconcile("hung", &session.current_cursor().await.expect("cursor"))
1127 .await
1128 .expect("reconcile"),
1129 ReconcileResult::StartedNoTerminal
1130 );
1131
1132 let error = session.execute(call("lost"), None).await.unwrap_err();
1133 assert_eq!(error.error_class, ErrorClass::TransportLost);
1134 assert_eq!(
1135 session
1136 .reconcile("lost", &session.current_cursor().await.expect("cursor"))
1137 .await
1138 .expect("reconcile"),
1139 ReconcileResult::NeverDispatched
1140 );
1141 }
1142
1143 #[tokio::test]
1144 async fn duplicate_call_id_is_rejected_without_consuming_script() {
1145 let provider = FakeProvider::new(VecDeque::from([
1146 ScriptedOutcome::succeeded(),
1147 ScriptedOutcome::failed("device_unavailable", true),
1148 ]));
1149 let session = open(&provider).await;
1150 session.execute(call("dup"), None).await.expect("first");
1151 let error = session.execute(call("dup"), None).await.unwrap_err();
1152 assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
1153 let outcome = session.execute(call("fresh"), None).await.expect("second");
1155 assert_eq!(outcome.kind(), "failed");
1156 }
1157
1158 #[tokio::test]
1159 async fn pre_cancelled_token_prevents_dispatch() {
1160 let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1161 let session = open(&provider).await;
1162 let token = CancellationToken::new();
1163 token.cancel();
1164 let error = session
1165 .execute(call("never-sent"), Some(token))
1166 .await
1167 .unwrap_err();
1168 assert_eq!(error.error_class, ErrorClass::ActionCancelled);
1169 assert_eq!(
1170 session
1171 .reconcile(
1172 "never-sent",
1173 &session.current_cursor().await.expect("cursor")
1174 )
1175 .await
1176 .expect("reconcile"),
1177 ReconcileResult::NeverDispatched
1178 );
1179 }
1180
1181 #[tokio::test]
1182 async fn unattested_action_is_refused_fail_closed() {
1183 let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1184 let session = open(&provider).await;
1185 let mut bogus = call("bogus");
1186 bogus.action_name = ActionName::new("launchMissiles").unwrap();
1187 let error = session.execute(bogus, None).await.unwrap_err();
1188 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
1189 assert!(provider.handle().dispatched_call_ids().is_empty());
1190 }
1191
1192 #[tokio::test]
1193 async fn observe_honors_wants_and_injected_omissions() {
1194 let provider = FakeProvider::new(VecDeque::new());
1195 let session = open(&provider).await;
1196 let observation = session
1197 .observe(
1198 ObserveRequest {
1199 wants: vec![ObserveWant::Screenshot, ObserveWant::UiSnapshot],
1200 },
1201 None,
1202 )
1203 .await
1204 .expect("observe");
1205 assert!(observation.screenshot.is_some());
1206 assert!(observation.ui_snapshot.is_some());
1207 assert!(observation.screenshot_omission.is_none());
1208
1209 provider
1210 .handle()
1211 .set_screenshot_omission(Some(ScreenshotOmissionReason::ProtectedAction));
1212 let observation = session
1213 .observe(
1214 ObserveRequest {
1215 wants: vec![ObserveWant::Screenshot],
1216 },
1217 None,
1218 )
1219 .await
1220 .expect("observe");
1221 assert!(observation.screenshot.is_none());
1222 assert_eq!(
1223 observation.screenshot_omission,
1224 Some(ScreenshotOmissionReason::ProtectedAction)
1225 );
1226 assert!(observation.ui_snapshot.is_none());
1228 assert!(observation.ui_snapshot_omission.is_none());
1229 }
1230
1231 #[tokio::test]
1232 async fn ui_snapshot_omission_yields_typed_unavailable() {
1233 let provider = FakeProvider::new(VecDeque::new());
1234 let session = open(&provider).await;
1235 provider
1236 .handle()
1237 .set_ui_snapshot_omission(Some(UiSnapshotOmissionReason::DriverUnsupported));
1238 let outcome = session.ui_snapshot("obs-1").await.expect("ui_snapshot");
1239 assert_eq!(
1240 outcome,
1241 UiSnapshotOutcome::Unavailable {
1242 reason: UiSnapshotOmissionReason::DriverUnsupported
1243 }
1244 );
1245 }
1246
1247 #[tokio::test]
1248 async fn injected_ui_snapshot_is_served_on_both_dereference_routes() {
1249 let provider = FakeProvider::new(VecDeque::new());
1250 let session = open(&provider).await;
1251 let tree = json!({
1252 "formatVersion": 1,
1253 "observationId": "ignored",
1254 "context": { "contextKind": "native", "contextId": "ctx-1", "documentEpoch": "e1" },
1255 "rootStableNodeIds": ["n1"],
1256 "nodes": [ { "stableNodeId": "n1", "role": "switch", "identifier": "wifi" } ],
1257 });
1258
1259 provider.handle().inject_ui_snapshot(Some(tree.clone()));
1261 let observation = session
1262 .observe(
1263 ObserveRequest {
1264 wants: vec![ObserveWant::UiSnapshot],
1265 },
1266 None,
1267 )
1268 .await
1269 .expect("observe");
1270 let snapshot_ref = observation.ui_snapshot.expect("ui snapshot ref");
1271 let outcome = session
1272 .ui_snapshot(&observation.id)
1273 .await
1274 .expect("ui_snapshot");
1275 assert_eq!(
1276 outcome,
1277 UiSnapshotOutcome::Available {
1278 snapshot: tree.clone()
1279 }
1280 );
1281 let mut stream = session
1282 .fetch_evidence(&snapshot_ref.evidence)
1283 .await
1284 .expect("stream");
1285 let mut bytes = Vec::new();
1286 while let Some(chunk) = stream.next().await {
1287 bytes.extend(chunk.expect("chunk"));
1288 }
1289 assert_eq!(bytes, serde_json::to_vec(&tree).expect("serialize"));
1290
1291 let synthesized = provider.handle().make_observation(Some(tree.clone()));
1294 assert!(synthesized.screenshot.is_some());
1295 let outcome = session
1296 .ui_snapshot(&synthesized.id)
1297 .await
1298 .expect("ui_snapshot");
1299 assert_eq!(outcome, UiSnapshotOutcome::Available { snapshot: tree });
1300 }
1301
1302 #[tokio::test]
1303 async fn fetch_evidence_streams_bytes_and_verifies_sha256() {
1304 let provider = FakeProvider::new(VecDeque::new());
1305 let session = open(&provider).await;
1306 let observation = session
1307 .observe(
1308 ObserveRequest {
1309 wants: vec![ObserveWant::Screenshot],
1310 },
1311 None,
1312 )
1313 .await
1314 .expect("observe");
1315 let asset = observation.screenshot.expect("screenshot asset");
1316
1317 let mut stream = session.fetch_evidence(&asset).await.expect("stream");
1318 let mut bytes = Vec::new();
1319 while let Some(chunk) = stream.next().await {
1320 bytes.extend(chunk.expect("chunk"));
1321 }
1322 assert_eq!(Some(hex_sha256(&bytes)), asset.sha256);
1323
1324 let mut tampered = asset.clone();
1325 tampered.sha256 = Some("0".repeat(64));
1326 let error = session
1327 .fetch_evidence(&tampered)
1328 .await
1329 .err()
1330 .expect("integrity failure");
1331 assert_eq!(error.error_class, ErrorClass::ActionFailedFinal);
1332 }
1333
1334 #[tokio::test]
1335 async fn record_verdict_caps_fail_closed() {
1336 let provider = FakeProvider::new(VecDeque::new());
1337 let session = open(&provider).await;
1338
1339 let oversized = VerdictWrite {
1340 status: VerdictStatus::Pass,
1341 summary: "x".repeat(VERDICT_SUMMARY_MAX_CHARS + 1),
1342 evidence: Vec::new(),
1343 };
1344 let error = session.record_verdict(oversized).await.unwrap_err();
1345 assert_eq!(error.error_class, ErrorClass::BindArgumentsInvalid);
1346
1347 let valid = VerdictWrite {
1348 status: VerdictStatus::Unknown,
1349 summary: "verify chain degraded".to_owned(),
1350 evidence: Vec::new(),
1351 };
1352 session.record_verdict(valid.clone()).await.expect("write");
1353 assert_eq!(provider.handle().recorded_verdicts(), vec![valid]);
1354 }
1355
1356 #[tokio::test]
1357 async fn end_is_idempotent_and_breaks_other_methods() {
1358 let provider = FakeProvider::new(VecDeque::from([ScriptedOutcome::succeeded()]));
1359 let session = open(&provider).await;
1360 session
1361 .end(SessionOutcome::Completed, Some("done".to_owned()))
1362 .await
1363 .expect("end");
1364 session
1365 .end(SessionOutcome::Failed, None)
1366 .await
1367 .expect("idempotent end");
1368 assert_eq!(
1369 provider.handle().ended_outcome(),
1370 Some((SessionOutcome::Completed, Some("done".to_owned())))
1371 );
1372 let health = session.health().await.expect("health");
1373 assert!(!health.ok);
1374 let error = session.execute(call("late"), None).await.unwrap_err();
1375 assert_eq!(error.error_class, ErrorClass::TransportLost);
1376 }
1377
1378 #[tokio::test]
1379 async fn open_session_fails_closed_on_digest_or_feature_drift() {
1380 let provider = FakeProvider::new(VecDeque::new());
1381
1382 let mut drifted = provider.default_open_options();
1383 drifted.lockfile_digest = Hash::new(format!("sha256:{}", "f".repeat(64))).unwrap();
1384 let error = provider.open_session(drifted).await.err().expect("drift");
1385 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
1386
1387 let mut unsatisfiable = provider.default_open_options();
1388 unsatisfiable
1389 .required_features
1390 .push(FeatureId::new("media.stream.v1").unwrap());
1391 let error = provider
1392 .open_session(unsatisfiable)
1393 .await
1394 .err()
1395 .expect("missing feature");
1396 assert_eq!(error.error_class, ErrorClass::CapabilityDrift);
1397 }
1398}