1use std::collections::{BTreeMap, BTreeSet};
4
5use crate::{
6 build_resume_liveness_plan, build_resume_schema_registry, ApplicationSemanticModel,
7 ResumeBoundaryId, ResumeBuildId, ResumeCaptureProgramId, ResumeExistingSlot, ResumeSchemaId,
8 ResumeSchemaRegistry, ResumeSlotId, ResumeSnapshotId, ResumeValueCodec, ResumeValueRecordId,
9 SerializableValue, SourceProvenance,
10};
11
12pub const RESUME_CAPTURE_PLAN_VERSION: u32 = 1;
13pub const RESUME_SNAPSHOT_SCHEMA_VERSION: u32 = 2;
14pub const RESUME_CAPTURE_MANIFEST_VERSION: u32 = 7;
15
16#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
17pub enum ResumeCaptureInstruction {
18 ReadSlot {
19 slot_id: ResumeSlotId,
20 },
21 EncodeSlot {
22 slot_id: ResumeSlotId,
23 codec: ResumeValueCodec,
24 },
25 AppendValueRecord {
26 value_record_id: ResumeValueRecordId,
27 slot_id: ResumeSlotId,
28 },
29}
30
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ResumeCaptureProgram {
33 pub program_id: ResumeCaptureProgramId,
34 pub boundary_id: ResumeBoundaryId,
35 pub instructions: Vec<ResumeCaptureInstruction>,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ResumeEnvelopeWriterPlan {
40 pub snapshot_schema_version: u32,
41 pub manifest_version: u32,
42 pub captured_at_is_null: bool,
43 pub boundary_programs: Vec<ResumeCaptureProgramId>,
44 pub standalone_trailing_newline: bool,
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
48pub enum ResumeCaptureBlockReason {
49 MissingBoundarySchema,
50 MissingRetainedSlotSchema,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct ResumeCaptureBlock {
55 pub boundary: Option<ResumeBoundaryId>,
56 pub slot: Option<ResumeExistingSlot>,
57 pub reason: ResumeCaptureBlockReason,
58 pub provenance: Option<SourceProvenance>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ResumeCapturePlan {
63 pub version: u32,
64 pub programs: Vec<ResumeCaptureProgram>,
65 pub envelope_writer: ResumeEnvelopeWriterPlan,
66 pub blocks: Vec<ResumeCaptureBlock>,
67 pub program_index: BTreeMap<ResumeBoundaryId, usize>,
68}
69
70impl ResumeCapturePlan {
71 #[must_use]
72 pub fn program(&self, boundary: &ResumeBoundaryId) -> Option<&ResumeCaptureProgram> {
73 self.program_index
74 .get(boundary)
75 .and_then(|index| self.programs.get(*index))
76 }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
80#[allow(clippy::struct_excessive_bools)]
81pub struct RuntimeQuiescenceState {
82 pub action_batch_depth: usize,
83 pub scheduler_pending_program_count: usize,
84 pub dom_patch_queue_empty: bool,
85 pub structural_program_active: bool,
86 pub form_submission_active: bool,
87 pub validation_execution_active: bool,
88 pub effect_execution_active: bool,
89 pub chunk_activation_active: bool,
90}
91
92impl RuntimeQuiescenceState {
93 #[must_use]
94 pub const fn quiescent() -> Self {
95 Self {
96 action_batch_depth: 0,
97 scheduler_pending_program_count: 0,
98 dom_patch_queue_empty: true,
99 structural_program_active: false,
100 form_submission_active: false,
101 validation_execution_active: false,
102 effect_execution_active: false,
103 chunk_activation_active: false,
104 }
105 }
106
107 #[must_use]
108 pub const fn is_quiescent(self) -> bool {
109 self.action_batch_depth == 0
110 && self.scheduler_pending_program_count == 0
111 && self.dom_patch_queue_empty
112 && !self.structural_program_active
113 && !self.form_submission_active
114 && !self.validation_execution_active
115 && !self.effect_execution_active
116 && !self.chunk_activation_active
117 }
118}
119
120#[derive(Debug, Clone, PartialEq, Eq)]
121pub struct ResumeEncodedValue(String);
122
123impl ResumeEncodedValue {
124 #[must_use]
125 pub fn as_str(&self) -> &str {
126 &self.0
127 }
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct ResumeSnapshotValueRecordV1 {
132 pub value_record_id: ResumeValueRecordId,
133 pub slot_id: ResumeSlotId,
134 pub value: ResumeEncodedValue,
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
138pub struct ResumeSnapshotBoundaryV1 {
139 pub boundary_id: ResumeBoundaryId,
140 pub schema_id: ResumeSchemaId,
141 pub values: Vec<ResumeSnapshotValueRecordV1>,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct ResumeSnapshotStructuralStateV2 {
147 pub slot_id: String,
148 pub value: ResumeEncodedValue,
149}
150
151#[derive(Debug, Clone, PartialEq, Eq)]
156pub struct ResumeSnapshotStructuralOccurrenceV2 {
157 pub occurrence_identity: String,
158 pub template_instance: String,
159 pub parent_scope: String,
160 pub structural_region: String,
161 pub local_occurrence: String,
162 pub state: Vec<ResumeSnapshotStructuralStateV2>,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq)]
166pub struct ResumeSnapshotV2 {
167 pub schema_version: u32,
168 pub build_id: ResumeBuildId,
169 pub snapshot_id: ResumeSnapshotId,
170 pub manifest_version: u32,
171 pub captured_at: Option<()>,
172 pub boundaries: Vec<ResumeSnapshotBoundaryV1>,
173 pub structural_occurrences: Vec<ResumeSnapshotStructuralOccurrenceV2>,
174}
175
176pub type ResumeSnapshotV1 = ResumeSnapshotV2;
179
180#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
181pub enum ResumeCaptureErrorKind {
182 NotQuiescent,
183 MissingExactSlot,
184 InvalidRuntimeShape,
185 InvalidNumber,
186 InvalidFormStableState,
187 ProgramIntegrity,
188}
189
190#[derive(Debug, Clone, PartialEq, Eq)]
191pub struct ResumeCaptureError {
192 pub kind: ResumeCaptureErrorKind,
193 pub boundary: Option<ResumeBoundaryId>,
194 pub slot: Option<ResumeSlotId>,
195 pub message: String,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
199pub enum ResumeCaptureIntegrityCode {
200 ProgramCorrespondence,
201 InvalidInstruction,
202 InvalidCaptureState,
203 OrderingOrOutputDrift,
204}
205
206impl ResumeCaptureIntegrityCode {
207 #[must_use]
208 pub const fn code(self) -> &'static str {
209 match self {
210 Self::ProgramCorrespondence => "PSASM1355",
211 Self::InvalidInstruction => "PSASM1356",
212 Self::InvalidCaptureState => "PSASM1357",
213 Self::OrderingOrOutputDrift => "PSASM1358",
214 }
215 }
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub struct ResumeCaptureIntegrityDiagnostic {
220 pub code: ResumeCaptureIntegrityCode,
221 pub boundary: Option<ResumeBoundaryId>,
222 pub message: String,
223}
224
225#[must_use]
226pub fn build_resume_capture_plan(model: &ApplicationSemanticModel) -> ResumeCapturePlan {
227 let schemas = build_resume_schema_registry(model);
228 let liveness = build_resume_liveness_plan(model);
229 let retained = liveness
230 .retained
231 .iter()
232 .map(|record| record.slot.existing_slot.clone())
233 .collect::<BTreeSet<_>>();
234 let mut captured = BTreeSet::new();
235 let mut programs = Vec::new();
236 let mut blocks = Vec::new();
237
238 for schema in &schemas.schemas {
239 let mut instructions = Vec::new();
240 for slot in &schema.slots {
241 if !retained.contains(&slot.existing_slot) {
242 continue;
243 }
244 captured.insert(slot.existing_slot.clone());
245 instructions.extend([
246 ResumeCaptureInstruction::ReadSlot {
247 slot_id: slot.resume_slot_id.clone(),
248 },
249 ResumeCaptureInstruction::EncodeSlot {
250 slot_id: slot.resume_slot_id.clone(),
251 codec: slot.codec.clone(),
252 },
253 ResumeCaptureInstruction::AppendValueRecord {
254 value_record_id: ResumeValueRecordId::for_slot(&slot.resume_slot_id),
255 slot_id: slot.resume_slot_id.clone(),
256 },
257 ]);
258 }
259 programs.push(ResumeCaptureProgram {
260 program_id: ResumeCaptureProgramId::for_boundary(&schema.boundary),
261 boundary_id: schema.boundary.clone(),
262 instructions,
263 });
264 }
265
266 for retained_slot in &liveness.retained {
267 if !captured.contains(&retained_slot.slot.existing_slot) {
268 blocks.push(ResumeCaptureBlock {
269 boundary: retained_slot.slot.boundary_candidate.clone(),
270 slot: Some(retained_slot.slot.existing_slot.clone()),
271 reason: ResumeCaptureBlockReason::MissingRetainedSlotSchema,
272 provenance: Some(retained_slot.slot.provenance.clone()),
273 });
274 }
275 }
276 for block in &schemas.blocks {
277 if retained.contains(&block.slot) {
278 blocks.push(ResumeCaptureBlock {
279 boundary: block.boundary.clone(),
280 slot: Some(block.slot.clone()),
281 reason: ResumeCaptureBlockReason::MissingRetainedSlotSchema,
282 provenance: Some(block.provenance.clone()),
283 });
284 }
285 }
286 blocks.sort_by(|left, right| {
287 (&left.boundary, &left.slot, left.reason).cmp(&(&right.boundary, &right.slot, right.reason))
288 });
289 blocks.dedup_by(|left, right| {
290 left.boundary == right.boundary && left.slot == right.slot && left.reason == right.reason
291 });
292 let program_index = programs
293 .iter()
294 .enumerate()
295 .map(|(index, program)| (program.boundary_id.clone(), index))
296 .collect();
297 let envelope_writer = ResumeEnvelopeWriterPlan {
298 snapshot_schema_version: RESUME_SNAPSHOT_SCHEMA_VERSION,
299 manifest_version: RESUME_CAPTURE_MANIFEST_VERSION,
300 captured_at_is_null: true,
301 boundary_programs: programs
302 .iter()
303 .map(|program| program.program_id.clone())
304 .collect(),
305 standalone_trailing_newline: true,
306 };
307
308 ResumeCapturePlan {
309 version: RESUME_CAPTURE_PLAN_VERSION,
310 programs,
311 envelope_writer,
312 blocks,
313 program_index,
314 }
315}
316
317#[allow(clippy::too_many_lines)]
326pub fn capture_resume_snapshot(
327 plan: &ResumeCapturePlan,
328 schemas: &ResumeSchemaRegistry,
329 quiescence: RuntimeQuiescenceState,
330 build_id: ResumeBuildId,
331 runtime_values: &BTreeMap<ResumeSlotId, SerializableValue>,
332) -> Result<ResumeSnapshotV1, ResumeCaptureError> {
333 if !quiescence.is_quiescent() {
334 return Err(capture_error(
335 ResumeCaptureErrorKind::NotQuiescent,
336 None,
337 None,
338 "capture requires an immediate compiler-defined quiescent point",
339 ));
340 }
341 if plan.version != RESUME_CAPTURE_PLAN_VERSION
342 || plan
343 .blocks
344 .iter()
345 .any(|block| block.reason == ResumeCaptureBlockReason::MissingRetainedSlotSchema)
346 || !capture_products_reciprocal(plan, schemas)
347 {
348 return Err(capture_error(
349 ResumeCaptureErrorKind::ProgramIntegrity,
350 None,
351 None,
352 "capture plan contains unresolved schema correspondence",
353 ));
354 }
355 let slot_schemas = schemas
356 .schemas
357 .iter()
358 .flat_map(|schema| {
359 schema
360 .slots
361 .iter()
362 .map(|slot| (slot.resume_slot_id.clone(), slot))
363 })
364 .collect::<BTreeMap<_, _>>();
365 let mut boundaries = Vec::new();
366
367 for program in &plan.programs {
368 let schema = schemas.schema(&program.boundary_id).ok_or_else(|| {
369 capture_error(
370 ResumeCaptureErrorKind::ProgramIntegrity,
371 Some(program.boundary_id.clone()),
372 None,
373 "capture program lacks its exact boundary schema",
374 )
375 })?;
376 let mut values = Vec::new();
377 for instruction_group in program.instructions.chunks_exact(3) {
378 let (
379 ResumeCaptureInstruction::ReadSlot { slot_id: read_slot },
380 ResumeCaptureInstruction::EncodeSlot {
381 slot_id: encode_slot,
382 codec,
383 },
384 ResumeCaptureInstruction::AppendValueRecord {
385 value_record_id,
386 slot_id: append_slot,
387 },
388 ) = (
389 &instruction_group[0],
390 &instruction_group[1],
391 &instruction_group[2],
392 )
393 else {
394 return Err(capture_error(
395 ResumeCaptureErrorKind::ProgramIntegrity,
396 Some(program.boundary_id.clone()),
397 None,
398 "capture instructions are not closed read/encode/append triples",
399 ));
400 };
401 if read_slot != encode_slot
402 || read_slot != append_slot
403 || value_record_id != &ResumeValueRecordId::for_slot(read_slot)
404 {
405 return Err(capture_error(
406 ResumeCaptureErrorKind::ProgramIntegrity,
407 Some(program.boundary_id.clone()),
408 Some(read_slot.clone()),
409 "capture instruction identities are not reciprocal",
410 ));
411 }
412 let slot_schema = slot_schemas.get(read_slot).ok_or_else(|| {
413 capture_error(
414 ResumeCaptureErrorKind::ProgramIntegrity,
415 Some(program.boundary_id.clone()),
416 Some(read_slot.clone()),
417 "capture slot lacks its generated schema",
418 )
419 })?;
420 if &slot_schema.codec != codec {
421 return Err(capture_error(
422 ResumeCaptureErrorKind::ProgramIntegrity,
423 Some(program.boundary_id.clone()),
424 Some(read_slot.clone()),
425 "capture codec drifted from the generated slot schema",
426 ));
427 }
428 let value = runtime_values.get(read_slot).ok_or_else(|| {
429 capture_error(
430 ResumeCaptureErrorKind::MissingExactSlot,
431 Some(program.boundary_id.clone()),
432 Some(read_slot.clone()),
433 "capture could not read the exact required runtime slot",
434 )
435 })?;
436 validate_form_stable_state(slot_schema, value, &program.boundary_id)?;
437 let encoded = encode_resume_value(value, codec).map_err(|mut error| {
438 error.boundary = Some(program.boundary_id.clone());
439 error.slot = Some(read_slot.clone());
440 error
441 })?;
442 values.push(ResumeSnapshotValueRecordV1 {
443 value_record_id: value_record_id.clone(),
444 slot_id: read_slot.clone(),
445 value: ResumeEncodedValue(encoded),
446 });
447 }
448 if !program.instructions.chunks_exact(3).remainder().is_empty() {
449 return Err(capture_error(
450 ResumeCaptureErrorKind::ProgramIntegrity,
451 Some(program.boundary_id.clone()),
452 None,
453 "capture program has an incomplete instruction group",
454 ));
455 }
456 boundaries.push(ResumeSnapshotBoundaryV1 {
457 boundary_id: program.boundary_id.clone(),
458 schema_id: schema.id.clone(),
459 values,
460 });
461 }
462
463 Ok(ResumeSnapshotV1 {
464 schema_version: RESUME_SNAPSHOT_SCHEMA_VERSION,
465 snapshot_id: ResumeSnapshotId::for_build(&build_id),
466 build_id,
467 manifest_version: RESUME_CAPTURE_MANIFEST_VERSION,
468 captured_at: None,
469 boundaries,
470 structural_occurrences: Vec::new(),
471 })
472}
473
474fn capture_products_reciprocal(plan: &ResumeCapturePlan, schemas: &ResumeSchemaRegistry) -> bool {
475 let program_boundaries = plan
476 .programs
477 .iter()
478 .map(|program| &program.boundary_id)
479 .collect::<Vec<_>>();
480 let schema_boundaries = schemas
481 .schemas
482 .iter()
483 .map(|schema| &schema.boundary)
484 .collect::<Vec<_>>();
485 let program_ids = plan
486 .programs
487 .iter()
488 .map(|program| program.program_id.clone())
489 .collect::<Vec<_>>();
490 program_boundaries == schema_boundaries
491 && plan.envelope_writer.snapshot_schema_version == RESUME_SNAPSHOT_SCHEMA_VERSION
492 && plan.envelope_writer.manifest_version == RESUME_CAPTURE_MANIFEST_VERSION
493 && plan.envelope_writer.captured_at_is_null
494 && plan.envelope_writer.standalone_trailing_newline
495 && plan.envelope_writer.boundary_programs == program_ids
496 && plan.program_index
497 == plan
498 .programs
499 .iter()
500 .enumerate()
501 .map(|(index, program)| (program.boundary_id.clone(), index))
502 .collect()
503}
504
505fn validate_form_stable_state(
506 slot_schema: &crate::ResumeSlotSchema,
507 value: &SerializableValue,
508 boundary: &ResumeBoundaryId,
509) -> Result<(), ResumeCaptureError> {
510 if !matches!(
511 slot_schema.existing_slot,
512 ResumeExistingSlot::FormSubmission(_)
513 ) {
514 return Ok(());
515 }
516 let SerializableValue::String(state) = value else {
517 return Err(capture_error(
518 ResumeCaptureErrorKind::InvalidRuntimeShape,
519 Some(boundary.clone()),
520 Some(slot_schema.resume_slot_id.clone()),
521 "Form submission state must be a compiler-owned string value",
522 ));
523 };
524 if matches!(state.as_str(), "Idle" | "Invalid" | "Failed" | "Completed") {
525 Ok(())
526 } else {
527 Err(capture_error(
528 ResumeCaptureErrorKind::InvalidFormStableState,
529 Some(boundary.clone()),
530 Some(slot_schema.resume_slot_id.clone()),
531 "pending or unknown Form submission state cannot enter a snapshot",
532 ))
533 }
534}
535
536pub fn encode_resume_value(
544 value: &SerializableValue,
545 codec: &ResumeValueCodec,
546) -> Result<String, ResumeCaptureError> {
547 match (value, codec) {
548 (
549 SerializableValue::Null,
550 ResumeValueCodec::NullCodec | ResumeValueCodec::NullableCodec(_),
551 ) => Ok("null".to_string()),
552 (SerializableValue::Boolean(value), ResumeValueCodec::BooleanCodec) => {
553 Ok(value.to_string())
554 }
555 (SerializableValue::Number(value), ResumeValueCodec::NumberCodec) => {
556 canonical_number(value)
557 }
558 (SerializableValue::String(value), ResumeValueCodec::StringCodec) => {
559 serde_json::to_string(value).map_err(|error| {
560 capture_error(
561 ResumeCaptureErrorKind::InvalidRuntimeShape,
562 None,
563 None,
564 &format!("string could not be canonically escaped: {error}"),
565 )
566 })
567 }
568 (SerializableValue::Array(values), ResumeValueCodec::ArrayCodec(element_codec)) => values
569 .iter()
570 .map(|value| encode_resume_value(value, element_codec))
571 .collect::<Result<Vec<_>, _>>()
572 .map(|values| format!("[{}]", values.join(","))),
573 (SerializableValue::Object(values), ResumeValueCodec::ObjectCodec(properties)) => {
574 if values.len() != properties.len()
575 || properties
576 .iter()
577 .any(|property| !values.contains_key(&property.name))
578 {
579 return Err(capture_error(
580 ResumeCaptureErrorKind::InvalidRuntimeShape,
581 None,
582 None,
583 "runtime object does not exactly match the generated property schema",
584 ));
585 }
586 properties
587 .iter()
588 .map(|property| {
589 let Some(value) = values.get(&property.name) else {
590 return Err(capture_error(
591 ResumeCaptureErrorKind::InvalidRuntimeShape,
592 None,
593 None,
594 "runtime object lost a generated schema property",
595 ));
596 };
597 let name = serde_json::to_string(&property.name).map_err(|error| {
598 capture_error(
599 ResumeCaptureErrorKind::InvalidRuntimeShape,
600 None,
601 None,
602 &format!("object property could not be escaped: {error}"),
603 )
604 })?;
605 Ok(format!(
606 "{name}:{}",
607 encode_resume_value(value, &property.codec)?
608 ))
609 })
610 .collect::<Result<Vec<_>, ResumeCaptureError>>()
611 .map(|properties| format!("{{{}}}", properties.join(",")))
612 }
613 (value, ResumeValueCodec::NullableCodec(inner)) => encode_resume_value(value, inner),
614 _ => Err(capture_error(
615 ResumeCaptureErrorKind::InvalidRuntimeShape,
616 None,
617 None,
618 "runtime value shape does not match the generated resume codec",
619 )),
620 }
621}
622
623fn canonical_number(value: &str) -> Result<String, ResumeCaptureError> {
624 let number = value.parse::<f64>().map_err(|_| {
625 capture_error(
626 ResumeCaptureErrorKind::InvalidNumber,
627 None,
628 None,
629 "snapshot number is not a finite JSON number",
630 )
631 })?;
632 if !number.is_finite() || (number == 0.0 && value.trim_start().starts_with('-')) {
633 return Err(capture_error(
634 ResumeCaptureErrorKind::InvalidNumber,
635 None,
636 None,
637 "snapshot numbers reject non-finite values and negative zero",
638 ));
639 }
640 Ok(number.to_string())
641}
642
643#[must_use]
644pub fn resume_snapshot_json(snapshot: &ResumeSnapshotV1) -> String {
645 let boundaries = snapshot
646 .boundaries
647 .iter()
648 .map(|boundary| {
649 let values = boundary
650 .values
651 .iter()
652 .map(|record| {
653 format!(
654 "{{\"valueRecordId\":{},\"slotId\":{},\"value\":{}}}",
655 json_string(&record.value_record_id.to_string()),
656 json_string(&record.slot_id.to_string()),
657 record.value.as_str()
658 )
659 })
660 .collect::<Vec<_>>()
661 .join(",");
662 format!(
663 "{{\"boundaryId\":{},\"schemaId\":{},\"values\":[{values}]}}",
664 json_string(&boundary.boundary_id.to_string()),
665 json_string(&boundary.schema_id.to_string())
666 )
667 })
668 .collect::<Vec<_>>()
669 .join(",");
670 let structural_occurrences = snapshot
671 .structural_occurrences
672 .iter()
673 .map(|occurrence| {
674 let state = occurrence
675 .state
676 .iter()
677 .map(|slot| format!(
678 "{{\"slotId\":{},\"value\":{}}}",
679 json_string(&slot.slot_id),
680 slot.value.as_str()
681 ))
682 .collect::<Vec<_>>()
683 .join(",");
684 format!(
685 "{{\"occurrenceIdentity\":{},\"templateInstance\":{},\"parentScope\":{},\"structuralRegion\":{},\"localOccurrence\":{},\"state\":[{state}]}}",
686 json_string(&occurrence.occurrence_identity),
687 json_string(&occurrence.template_instance),
688 json_string(&occurrence.parent_scope),
689 json_string(&occurrence.structural_region),
690 json_string(&occurrence.local_occurrence),
691 )
692 })
693 .collect::<Vec<_>>()
694 .join(",");
695 format!(
696 "{{\"schemaVersion\":{},\"buildId\":{},\"snapshotId\":{},\"manifestVersion\":{},\"capturedAt\":null,\"boundaries\":[{boundaries}],\"structuralOccurrences\":[{structural_occurrences}]}}",
697 snapshot.schema_version,
698 json_string(&snapshot.build_id.to_string()),
699 json_string(&snapshot.snapshot_id.to_string()),
700 snapshot.manifest_version,
701 )
702}
703
704#[must_use]
705pub fn resume_snapshot_artifact_json(snapshot: &ResumeSnapshotV1) -> String {
706 resume_snapshot_json(snapshot) + "\n"
707}
708
709fn json_string(value: &str) -> String {
710 serde_json::to_string(value).expect("resume identity string serializes")
711}
712
713pub fn validate_resume_capture_plan(
718 model: &ApplicationSemanticModel,
719 plan: &ResumeCapturePlan,
720) -> Result<(), Vec<ResumeCaptureIntegrityDiagnostic>> {
721 let mut diagnostics = Vec::new();
722 let schemas = build_resume_schema_registry(model);
723 let expected_boundaries = schemas
724 .schemas
725 .iter()
726 .map(|schema| schema.boundary.clone())
727 .collect::<Vec<_>>();
728 let actual_boundaries = plan
729 .programs
730 .iter()
731 .map(|program| program.boundary_id.clone())
732 .collect::<Vec<_>>();
733 if expected_boundaries != actual_boundaries {
734 diagnostics.push(integrity_diagnostic(
735 ResumeCaptureIntegrityCode::ProgramCorrespondence,
736 None,
737 "capture programs do not cover schemas in parent-before-child order",
738 ));
739 }
740 for program in &plan.programs {
741 if program.program_id != ResumeCaptureProgramId::for_boundary(&program.boundary_id)
742 || !program.instructions.chunks_exact(3).remainder().is_empty()
743 {
744 diagnostics.push(integrity_diagnostic(
745 ResumeCaptureIntegrityCode::InvalidInstruction,
746 Some(program.boundary_id.clone()),
747 "capture program identity or instruction grouping is invalid",
748 ));
749 continue;
750 }
751 for group in program.instructions.chunks_exact(3) {
752 let valid = matches!(
753 (&group[0], &group[1], &group[2]),
754 (
755 ResumeCaptureInstruction::ReadSlot { slot_id: read },
756 ResumeCaptureInstruction::EncodeSlot { slot_id: encode, .. },
757 ResumeCaptureInstruction::AppendValueRecord {
758 value_record_id,
759 slot_id: append
760 }
761 ) if read == encode
762 && read == append
763 && value_record_id == &ResumeValueRecordId::for_slot(read)
764 );
765 if !valid {
766 diagnostics.push(integrity_diagnostic(
767 ResumeCaptureIntegrityCode::InvalidInstruction,
768 Some(program.boundary_id.clone()),
769 "capture instructions are not exact read/encode/append triples",
770 ));
771 }
772 }
773 }
774 if plan.envelope_writer.snapshot_schema_version != RESUME_SNAPSHOT_SCHEMA_VERSION
775 || plan.envelope_writer.manifest_version != RESUME_CAPTURE_MANIFEST_VERSION
776 || !plan.envelope_writer.captured_at_is_null
777 || !plan.envelope_writer.standalone_trailing_newline
778 {
779 diagnostics.push(integrity_diagnostic(
780 ResumeCaptureIntegrityCode::InvalidCaptureState,
781 None,
782 "capture envelope policy violates snapshot v1 determinism",
783 ));
784 }
785 if plan != &build_resume_capture_plan(model) {
786 diagnostics.push(integrity_diagnostic(
787 ResumeCaptureIntegrityCode::OrderingOrOutputDrift,
788 None,
789 "capture plan drifted from canonical J2/J6 construction",
790 ));
791 }
792
793 if diagnostics.is_empty() {
794 Ok(())
795 } else {
796 Err(diagnostics)
797 }
798}
799
800fn capture_error(
801 kind: ResumeCaptureErrorKind,
802 boundary: Option<ResumeBoundaryId>,
803 slot: Option<ResumeSlotId>,
804 message: &str,
805) -> ResumeCaptureError {
806 ResumeCaptureError {
807 kind,
808 boundary,
809 slot,
810 message: message.to_string(),
811 }
812}
813
814fn integrity_diagnostic(
815 code: ResumeCaptureIntegrityCode,
816 boundary: Option<ResumeBoundaryId>,
817 message: &str,
818) -> ResumeCaptureIntegrityDiagnostic {
819 ResumeCaptureIntegrityDiagnostic {
820 code,
821 boundary,
822 message: message.to_string(),
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829 use crate::{ObjectType, ResumeObjectPropertyCodec, SemanticType};
830
831 fn model(source: &str) -> ApplicationSemanticModel {
832 crate::build_application_semantic_model(&presolve_parser::parse_file(
833 "src/ResumeCapture.tsx",
834 source,
835 ))
836 }
837
838 fn value_for_codec(codec: &ResumeValueCodec) -> SerializableValue {
839 match codec {
840 ResumeValueCodec::NullCodec | ResumeValueCodec::NullableCodec(_) => {
841 SerializableValue::Null
842 }
843 ResumeValueCodec::BooleanCodec => SerializableValue::Boolean(false),
844 ResumeValueCodec::NumberCodec => SerializableValue::Number("1".to_string()),
845 ResumeValueCodec::StringCodec => SerializableValue::String(String::new()),
846 ResumeValueCodec::ArrayCodec(_) => SerializableValue::Array(Vec::new()),
847 ResumeValueCodec::ObjectCodec(properties) => SerializableValue::Object(
848 properties
849 .iter()
850 .map(|property| (property.name.clone(), value_for_codec(&property.codec)))
851 .collect(),
852 ),
853 }
854 }
855
856 fn capture_values(
857 plan: &ResumeCapturePlan,
858 schemas: &ResumeSchemaRegistry,
859 ) -> BTreeMap<ResumeSlotId, SerializableValue> {
860 let captured = plan
861 .programs
862 .iter()
863 .flat_map(|program| &program.instructions)
864 .filter_map(|instruction| match instruction {
865 ResumeCaptureInstruction::ReadSlot { slot_id } => Some(slot_id),
866 ResumeCaptureInstruction::EncodeSlot { .. }
867 | ResumeCaptureInstruction::AppendValueRecord { .. } => None,
868 })
869 .collect::<BTreeSet<_>>();
870 schemas
871 .schemas
872 .iter()
873 .flat_map(|schema| &schema.slots)
874 .filter(|slot| captured.contains(&slot.resume_slot_id))
875 .map(|slot| (slot.resume_slot_id.clone(), value_for_codec(&slot.codec)))
876 .collect()
877 }
878
879 #[test]
880 fn encodes_every_closed_value_variant_in_canonical_order() {
881 let object_codec = ResumeValueCodec::ObjectCodec(vec![
882 ResumeObjectPropertyCodec {
883 name: "a".to_string(),
884 codec: ResumeValueCodec::NumberCodec,
885 },
886 ResumeObjectPropertyCodec {
887 name: "b".to_string(),
888 codec: ResumeValueCodec::StringCodec,
889 },
890 ]);
891 let fixtures = [
892 (SerializableValue::Null, ResumeValueCodec::NullCodec, "null"),
893 (
894 SerializableValue::Boolean(true),
895 ResumeValueCodec::BooleanCodec,
896 "true",
897 ),
898 (
899 SerializableValue::Number("1.50".to_string()),
900 ResumeValueCodec::NumberCodec,
901 "1.5",
902 ),
903 (
904 SerializableValue::String("a\nb".to_string()),
905 ResumeValueCodec::StringCodec,
906 "\"a\\nb\"",
907 ),
908 (
909 SerializableValue::Array(vec![
910 SerializableValue::Number("1".to_string()),
911 SerializableValue::Number("2".to_string()),
912 ]),
913 ResumeValueCodec::ArrayCodec(Box::new(ResumeValueCodec::NumberCodec)),
914 "[1,2]",
915 ),
916 (
917 SerializableValue::Object(BTreeMap::from([
918 (
919 "b".to_string(),
920 SerializableValue::String("two".to_string()),
921 ),
922 ("a".to_string(), SerializableValue::Number("1".to_string())),
923 ])),
924 object_codec,
925 "{\"a\":1,\"b\":\"two\"}",
926 ),
927 (
928 SerializableValue::Null,
929 ResumeValueCodec::NullableCodec(Box::new(ResumeValueCodec::StringCodec)),
930 "null",
931 ),
932 ];
933 for (value, codec, expected) in fixtures {
934 assert_eq!(encode_resume_value(&value, &codec).as_deref(), Ok(expected));
935 let parsed: serde_json::Value =
936 serde_json::from_str(expected).expect("canonical fixture JSON");
937 assert_eq!(
938 serde_json::from_str::<serde_json::Value>(
939 &encode_resume_value(&value, &codec).expect("encode")
940 )
941 .expect("encoded JSON"),
942 parsed
943 );
944 }
945 }
946
947 #[test]
948 fn rejects_negative_zero_non_finite_and_runtime_shape_guessing() {
949 for number in ["-0", "-0.0", "NaN", "inf", "-inf", "1e9999"] {
950 assert_eq!(
951 encode_resume_value(
952 &SerializableValue::Number(number.to_string()),
953 &ResumeValueCodec::NumberCodec,
954 )
955 .expect_err("invalid number")
956 .kind,
957 ResumeCaptureErrorKind::InvalidNumber
958 );
959 }
960 assert_eq!(
961 encode_resume_value(
962 &SerializableValue::Object(BTreeMap::new()),
963 &ResumeValueCodec::NumberCodec,
964 )
965 .expect_err("shape mismatch")
966 .kind,
967 ResumeCaptureErrorKind::InvalidRuntimeShape
968 );
969 }
970
971 #[test]
972 fn generates_one_exact_program_per_boundary_and_omits_recomputable_slots() {
973 let model = model(
974 r#"@component("x-counter") class Counter {
975 count = state(1);
976 @computed() get doubled() { return this.count * 2; }
977 render() { return <button>{this.doubled}</button>; }
978}"#,
979 );
980 let schemas = build_resume_schema_registry(&model);
981 let liveness = build_resume_liveness_plan(&model);
982 let plan = build_resume_capture_plan(&model);
983 assert_eq!(plan.programs.len(), schemas.schemas.len());
984 let captured = plan
985 .programs
986 .iter()
987 .flat_map(|program| &program.instructions)
988 .filter_map(|instruction| match instruction {
989 ResumeCaptureInstruction::ReadSlot { slot_id } => Some(slot_id.clone()),
990 ResumeCaptureInstruction::EncodeSlot { .. }
991 | ResumeCaptureInstruction::AppendValueRecord { .. } => None,
992 })
993 .collect::<BTreeSet<_>>();
994 assert!(liveness
995 .retained
996 .iter()
997 .all(|record| captured.contains(&record.slot.resume_slot_id)));
998 assert!(liveness
999 .recomputable
1000 .iter()
1001 .all(|record| !captured.contains(&record.slot.resume_slot_id)));
1002 assert_eq!(validate_resume_capture_plan(&model, &plan), Ok(()));
1003 }
1004
1005 #[test]
1006 fn equal_quiescent_state_produces_equal_bytes_without_mutation_or_timestamp() {
1007 let model = model(
1008 r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
1009 );
1010 let schemas = build_resume_schema_registry(&model);
1011 let plan = build_resume_capture_plan(&model);
1012 let values = capture_values(&plan, &schemas);
1013 let before = values.clone();
1014 let build = ResumeBuildId::for_public_inputs("capture-test");
1015 let first = capture_resume_snapshot(
1016 &plan,
1017 &schemas,
1018 RuntimeQuiescenceState::quiescent(),
1019 build.clone(),
1020 &values,
1021 )
1022 .expect("capture");
1023 let second = capture_resume_snapshot(
1024 &plan,
1025 &schemas,
1026 RuntimeQuiescenceState::quiescent(),
1027 build,
1028 &values,
1029 )
1030 .expect("capture");
1031 assert_eq!(values, before);
1032 assert_eq!(resume_snapshot_json(&first), resume_snapshot_json(&second));
1033 assert!(resume_snapshot_json(&first).contains("\"capturedAt\":null"));
1034 assert!(!resume_snapshot_json(&first).ends_with('\n'));
1035 assert!(resume_snapshot_artifact_json(&first).ends_with('\n'));
1036 }
1037
1038 #[test]
1039 fn rejects_malformed_program_products_before_reading_slots() {
1040 let model = model(
1041 r#"@component("x-counter") class Counter { count = state(1); render() { return <button>{this.count}</button>; } }"#,
1042 );
1043 let schemas = build_resume_schema_registry(&model);
1044 let mut plan = build_resume_capture_plan(&model);
1045 plan.programs.pop();
1046 let diagnostics = validate_resume_capture_plan(&model, &plan).expect_err("malformed plan");
1047 assert!(diagnostics.iter().any(|diagnostic| {
1048 diagnostic.code == ResumeCaptureIntegrityCode::ProgramCorrespondence
1049 }));
1050 assert_eq!(
1051 capture_resume_snapshot(
1052 &plan,
1053 &schemas,
1054 RuntimeQuiescenceState::quiescent(),
1055 ResumeBuildId::for_public_inputs("malformed"),
1056 &BTreeMap::new(),
1057 )
1058 .expect_err("program integrity")
1059 .kind,
1060 ResumeCaptureErrorKind::ProgramIntegrity
1061 );
1062 }
1063
1064 #[test]
1065 fn rejects_non_quiescent_and_pending_form_capture() {
1066 let model = model(
1067 r#"@component("x-profile") class Profile {
1068 @form() profile!: Form;
1069 @field(this.profile) name = "";
1070 render() { return <input field={this.name} />; }
1071}"#,
1072 );
1073 let schemas = build_resume_schema_registry(&model);
1074 let plan = build_resume_capture_plan(&model);
1075 let mut values = capture_values(&plan, &schemas);
1076 let submission = schemas
1077 .schemas
1078 .iter()
1079 .flat_map(|schema| &schema.slots)
1080 .find(|slot| matches!(slot.existing_slot, ResumeExistingSlot::FormSubmission(_)))
1081 .expect("submission slot");
1082 values.insert(
1083 submission.resume_slot_id.clone(),
1084 SerializableValue::String("Submitting".to_string()),
1085 );
1086 assert_eq!(
1087 capture_resume_snapshot(
1088 &plan,
1089 &schemas,
1090 RuntimeQuiescenceState::quiescent(),
1091 ResumeBuildId::for_public_inputs("pending-form"),
1092 &values,
1093 )
1094 .expect_err("pending submission")
1095 .kind,
1096 ResumeCaptureErrorKind::InvalidFormStableState
1097 );
1098 let mut active = RuntimeQuiescenceState::quiescent();
1099 active.validation_execution_active = true;
1100 assert_eq!(
1101 capture_resume_snapshot(
1102 &plan,
1103 &schemas,
1104 active,
1105 ResumeBuildId::for_public_inputs("active-validation"),
1106 &values,
1107 )
1108 .expect_err("not quiescent")
1109 .kind,
1110 ResumeCaptureErrorKind::NotQuiescent
1111 );
1112 }
1113
1114 #[test]
1115 fn reserves_the_complete_j7_integrity_range() {
1116 assert_eq!(
1117 [
1118 ResumeCaptureIntegrityCode::ProgramCorrespondence,
1119 ResumeCaptureIntegrityCode::InvalidInstruction,
1120 ResumeCaptureIntegrityCode::InvalidCaptureState,
1121 ResumeCaptureIntegrityCode::OrderingOrOutputDrift,
1122 ]
1123 .map(ResumeCaptureIntegrityCode::code),
1124 ["PSASM1355", "PSASM1356", "PSASM1357", "PSASM1358"]
1125 );
1126 }
1127
1128 #[test]
1129 fn capture_plan_is_deterministic_under_source_reversal() {
1130 let first = presolve_parser::parse_file(
1131 "src/A.tsx",
1132 r#"@component("x-a") @route("/a") class A { value = state(1); render() { return <button>{this.value}</button>; } }"#,
1133 );
1134 let second = presolve_parser::parse_file(
1135 "src/B.tsx",
1136 r#"@component("x-b") @route("/b") class B { enabled = state(true); render() { return <button>{this.enabled}</button>; } }"#,
1137 );
1138 let forward = crate::build_application_semantic_model_for_unit(
1139 &crate::CompilationUnit::from_parsed_files(vec![first.clone(), second.clone()]),
1140 );
1141 let reverse = crate::build_application_semantic_model_for_unit(
1142 &crate::CompilationUnit::from_parsed_files(vec![second, first]),
1143 );
1144 assert_eq!(
1145 build_resume_capture_plan(&forward),
1146 build_resume_capture_plan(&reverse)
1147 );
1148 }
1149
1150 #[test]
1151 fn codec_fixture_type_remains_semantically_closed() {
1152 let semantic_type = SemanticType::Object(ObjectType {
1153 properties: BTreeMap::from([("value".to_string(), SemanticType::Number)]),
1154 });
1155 assert!(crate::resume_value_codec(&semantic_type).is_ok());
1156 }
1157}