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