diff --git a/.github/workflows/server-parity.yml b/.github/workflows/server-parity.yml index 843bc2b26..624958ec4 100644 --- a/.github/workflows/server-parity.yml +++ b/.github/workflows/server-parity.yml @@ -45,6 +45,7 @@ on: - tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs - tests/Unit/ServerParityActivityCancellationAckContractTest.mjs - tests/Unit/ServerParityServiceCatalogContractTest.mjs + - tests/Unit/ServerParityWorkflowImportContractTest.mjs - tests/Unit/ServerParitySystemMetricsContractTest.mjs - tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs - tests/Unit/ServerParityPrometheusMetricsContractTest.mjs @@ -1800,7 +1801,7 @@ jobs: bash scripts/ci/run-composer.sh --rm -v "$PWD:/source" -w /source -- \ install --working-dir=scripts/conformance/server-parity --no-interaction --no-progress docker run --rm --user=1000:1000 --entrypoint node -v "$PWD:/source:ro" -w /source \ - "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs > parity-evidence/comparator-tests.txt + "$image" --test tests/Unit/ServerParityRunnerTest.mjs tests/Unit/ServerParitySdkDirectionLanesTest.mjs tests/Unit/ServerParityCodecDeadlineCellsTest.mjs tests/Unit/ServerParityCooperativeContractTest.mjs tests/Unit/ServerParityChildCancellationContractTest.mjs tests/Unit/ServerParityScheduleContractTest.mjs tests/Unit/ServerParityVisibilityContractTest.mjs tests/Unit/ServerParityAdmissionContractTest.mjs tests/Unit/ServerParityRoleTokenContractTest.mjs tests/Unit/ServerParityWorkerDeregistrationContractTest.mjs tests/Unit/ServerParitySearchAttributeContractTest.mjs tests/Unit/ServerParityWorkerListingContractTest.mjs tests/Unit/ServerParityWorkerReplacementContractTest.mjs tests/Unit/ServerParityPatchDeploymentContractTest.mjs tests/Unit/ServerParityWaitingHistoryContractTest.mjs tests/Unit/ServerParityTaskRetryContractTest.mjs tests/Unit/ServerParityPublishedPatchContractTest.mjs tests/Unit/ServerParityPublishedPatchTransportTest.mjs tests/Unit/ServerParityRecordFileTest.mjs tests/Unit/ServerParityFailureLocationTest.mjs tests/Unit/ServerParityFailureExcerptTest.mjs tests/Unit/ServerParityLateWorkflowCompletionContractTest.mjs tests/Unit/ServerParityStorageTestContractTest.mjs tests/Unit/ServerParitySchedulePoliciesContractTest.mjs tests/Unit/ServerParitySignalsBeforeWorkerContractTest.mjs tests/Unit/ServerParityScheduleCatalogContractTest.mjs tests/Unit/ServerParityOperatorMetricsContractTest.mjs tests/Unit/ServerParityActivityLeaseRenewalContractTest.mjs tests/Unit/ServerParityActivityCancellationAckContractTest.mjs tests/Unit/ServerParityServiceCatalogContractTest.mjs tests/Unit/ServerParityWorkflowImportContractTest.mjs tests/Unit/ServerParitySystemMetricsContractTest.mjs tests/Unit/ServerParityOperatorDashboardTypesContractTest.mjs tests/Unit/ServerParityPrometheusMetricsContractTest.mjs tests/Unit/ServerParityPayloadCleanupContractTest.mjs tests/Unit/ServerParityRepairPassContractTest.mjs tests/Unit/ServerParityClusterInfoContractTest.mjs tests/Unit/ServerParityNamespaceDeleteContractTest.mjs tests/Unit/ServerParityMessageStreamsContractTest.mjs tests/Unit/ServerParityWorkflowPollRequestContractTest.mjs tests/Unit/ServerParityUpdateValidationContractTest.mjs tests/Unit/ServerParityFixtureSelectionTest.mjs tests/Unit/ServerParityUpdateValidationRetryTest.mjs tests/Unit/ServerParityKilledAuthorPollTest.mjs tests/Unit/ServerParityImagePullTest.mjs tests/Unit/ServerParityWorkerSessionsContractTest.mjs tests/Unit/ServerParityWorkerSessionActivitiesContractTest.mjs > parity-evidence/comparator-tests.txt docker run --rm --user=1000:1000 --cpus=1 --memory=256m --memory-swap=256m \ --network=none --entrypoint node -v "$PWD:/source:ro" -w /source \ "$image" --test tests/Unit/ServerParityCodecDeadlineLoadTest.mjs > parity-evidence/codec-load-tests.txt diff --git a/docs/rust-server-port.md b/docs/rust-server-port.md index d45b88ed6..1723f93c4 100644 --- a/docs/rust-server-port.md +++ b/docs/rust-server-port.md @@ -82,11 +82,12 @@ Updated October 24, 2026. ([#737](https://github.com/durable-workflow/server/issues/737)). No expectation is edited to make native pass. -- **Parity surface.** Native serves 138 of the 175 API route methods in +- **Parity surface.** Native serves 139 of the 175 API route methods in published 2.11.1's route table, with message streams, update validation, - activity cancellation acknowledgement and worker sessions. An execution - test sends each one through the native router, and the ones still - answering `501 rust_capability_not_implemented` must match + activity cancellation acknowledgement, worker sessions and embedded v2 + workflow import. An execution test sends each one through the native + router, and the ones still answering `501 rust_capability_not_implemented` + must match [`unserved-php-routes.v2.11.1.txt`](../rust/tests/fixtures/unserved-php-routes.v2.11.1.txt). - **Next action.** Land the items above in order, keep porting the unserved routes fixture first, and package the native binary into the server image. @@ -132,12 +133,15 @@ any other unknown path or method answers PHP's own 404 or 405 `repair-pass` fixture; a recovery native does not perform yet is reported as its candidate's failure, with 207. - **Worker protocol features.** - - Worker sessions. - Local activities (prepare, checkpoint, recover, heartbeat, control, outcome and cancellation acknowledgement). - Cancellation scopes. - - Activity cancellation acknowledgement. (Lease renewal, from Server 2.11, - is served: `POST /api/worker/activity-tasks/{task}/renew-lease`, with the + - (Worker sessions are served: create, heartbeat, close, list and detail, + with the `worker-sessions` and `worker-session-activities` fixtures.) + - (Activity cancellation acknowledgement is served: + `POST /api/worker/activity-tasks/{task}/acknowledge-cancellation`, with the + `activity-cancellation-acknowledgement` fixture. So is lease renewal, from + Server 2.11: `POST /api/worker/activity-tasks/{task}/renew-lease`, with the `activity_lease_renewal` capability and the `activity-lease-renewal` fixture.) - (Synchronous update validation is served: the validation task poll, approve and reject routes, the multiplexed workflow poll with @@ -146,7 +150,11 @@ any other unknown path or method answers PHP's own 404 or 405 `poll_request_id` or bind a request id to its task kinds; see `docs/rust-server-port-checkpoints/2026-10-23-update-validation.md`.) - **Run operations.** - - Imports from embedded v2 and Waterline v1. + - Waterline v1 import. (Embedded v2 import is served: + `POST /api/workflows/import/embedded-v2`. Unlike published PHP, native + refuses a bundle whose rows reference rows outside it, links only runs of + one namespace, and reads the signing key only from the request body + ([#765](https://github.com/durable-workflow/server/issues/765)).) - **Other families.** - Message streams are served ([checkpoint](rust-server-port-checkpoints/2026-10-23-message-streams.md)), diff --git a/rust/src/codec.rs b/rust/src/codec.rs index e4480854f..c32bcc913 100644 --- a/rust/src/codec.rs +++ b/rust/src/codec.rs @@ -26,7 +26,7 @@ const PHP_BASE64: GeneralPurpose = GeneralPurpose::new( /// Decodes a blob as published PHP does: ASCII whitespace is skipped, and /// any padding must be exactly what the final quantum needs. -fn php_base64(blob: &str) -> Result, base64::DecodeError> { +pub(crate) fn php_base64(blob: &str) -> Result, base64::DecodeError> { let compact: String = blob.chars().filter(|c| !matches!(c, ' ' | '\t' | '\n' | '\r')).collect(); let unpadded = compact.trim_end_matches('='); let padding = compact.len() - unpadded.len(); diff --git a/rust/src/runtime/activity_deadlines.rs b/rust/src/runtime/activity_deadlines.rs index 9d9995d1f..696c89216 100644 --- a/rust/src/runtime/activity_deadlines.rs +++ b/rust/src/runtime/activity_deadlines.rs @@ -312,8 +312,10 @@ where let (Some(task), Some(execution_id)) = (task, execution_id) else { return Ok(Skipped("activity_execution_missing")); }; - let Some(activity) = Self::query("SELECT * FROM activity_executions WHERE id=$1") + // Only the task's own run's activity (Server #765). + let Some(activity) = Self::query("SELECT * FROM activity_executions WHERE id=$1 AND workflow_run_id=$2") .bind(&execution_id) + .bind(DB::string(&task, "workflow_run_id")?) .fetch_optional(&mut **tx) .await? else { diff --git a/rust/src/runtime/backend.rs b/rust/src/runtime/backend.rs index 8d92da79a..78cfbae9c 100644 --- a/rust/src/runtime/backend.rs +++ b/rust/src/runtime/backend.rs @@ -39,7 +39,8 @@ pub(super) trait Backend: Database { const RUN_COMMANDS_SQL: &str = super::store::run_commands_sql!( "substr(s.arguments,1,30)='dw-native-external-payload-v1:'", - "substr(c.payload,1,30)='dw-native-external-payload-v1:'" + "substr(c.payload,1,30)='dw-native-external-payload-v1:'", + "substr(c.payload,1,23)='dw-external-payload:v1:'" ); const SELECTION_RESOLUTION_SQL: &str = "SELECT id,payload FROM workflow_history_events WHERE workflow_run_id=$1 AND event_type='SelectionResolved' AND payload->>'selection_group_id'=$2 ORDER BY sequence LIMIT 1"; // Frozen run command/task repair counters use each backend's actual width. @@ -205,7 +206,8 @@ ORDER BY t.available_order,t.id LIMIT 100 const RUN_COMMANDS_SQL: &str = super::store::run_commands_sql!( "substr(s.arguments,1,30) COLLATE BINARY='dw-native-external-payload-v1:'", - "substr(c.payload,1,30) COLLATE BINARY='dw-native-external-payload-v1:'" + "substr(c.payload,1,30) COLLATE BINARY='dw-native-external-payload-v1:'", + "substr(c.payload,1,23) COLLATE BINARY='dw-external-payload:v1:'" ); const HISTORY_PAGE: &str = "SELECT * FROM (SELECT *,SUM(length(CAST(payload AS BLOB))) OVER (ORDER BY sequence) AS page_bytes FROM workflow_history_events WHERE workflow_run_id=$1 AND sequence>$2 ORDER BY sequence LIMIT $3) AS page WHERE page_bytes<=8388608 ORDER BY sequence"; // Normalize only the two supported UTC storage shapes. Removing trailing @@ -487,7 +489,8 @@ impl Backend for MySql { const RUN_COMMANDS_SQL: &str = super::store::run_commands_sql!( "CAST(SUBSTRING(s.arguments,1,30) AS BINARY)='dw-native-external-payload-v1:'", - "CAST(SUBSTRING(c.payload,1,30) AS BINARY)='dw-native-external-payload-v1:'" + "CAST(SUBSTRING(c.payload,1,30) AS BINARY)='dw-native-external-payload-v1:'", + "CAST(SUBSTRING(c.payload,1,23) AS BINARY)='dw-external-payload:v1:'" ); // First use the preserved run/sequence index to materialize only IDs and // byte counts for this bounded page. Sorting JSON payloads in the window diff --git a/rust/src/runtime/bundle_verifier.rs b/rust/src/runtime/bundle_verifier.rs new file mode 100644 index 000000000..d9e1f74fb --- /dev/null +++ b/rust/src/runtime/bundle_verifier.rs @@ -0,0 +1,771 @@ +//! `BundleIntegrityVerifier` (Workflow 2.10.2, as published Server 2.11.1 +//! ships it): the offline integrity report of a history-export bundle, over +//! the document PHP decoded and Laravel normalized. Findings keep PHP's check +//! order, rules, messages, paths and contexts; the checksum and signature are +//! computed over PHP's canonical JSON. +use super::history_export::{SCHEMA, VALUE_FINGERPRINT}; +use super::php_value::{CANONICAL, Flags, Key, Php, PhpArray}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use hmac::{Hmac, KeyInit, Mac}; +use serde_json::{Map, Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +const SCHEMA_VERSION: i64 = 2; +const ERROR: &str = "error"; +const WARNING: &str = "warning"; +const INFO: &str = "info"; + +/// `MemoPayload::KEY_PATTERN`. +const MEMO_KEY_PATTERN: &str = "^(?!-?[0-9]+$)[A-Za-z0-9_.:-]{1,64}$"; + +fn string_value(value: Option<&Php>) -> Option<&str> { + value.and_then(Php::str).filter(|text| !text.is_empty()) +} + +/// The verifier's `intValue`: an integer, or a string that is exactly one. +fn int_value(value: Option<&Php>) -> Option { + match value? { + Php::Int(number) => Some(*number), + Php::Str(text) => text.parse::().ok().filter(|number| number.to_string() == *text), + _ => None, + } +} + +fn array(value: Option<&Php>) -> Option<&PhpArray> { + value.and_then(Php::array) +} + +struct Findings(Vec); + +impl Findings { + fn add(&mut self, rule: &str, severity: &str, message: String, path: Option, context: Value) { + let context = if context.as_object().is_some_and(Map::is_empty) { json!([]) } else { context }; + self.0.push(json!({"rule":rule,"severity":severity,"message":message,"path":path,"context":context})); + } + + fn error(&mut self, rule: &str, message: impl Into, path: impl Into) { + self.add(rule, ERROR, message.into(), Some(path.into()), json!({})); + } +} + +/// The verification report and its status. +pub(super) struct Verification { + pub(super) report: Value, + pub(super) status: &'static str, + /// The decoded memo entries, when `workflow.memo_payload` decoded. + pub(super) memo_entries: Option>, +} + +/// `BundleIntegrityVerifier::verify`. +pub(super) fn verify(bundle: &PhpArray, signing_key: Option<&str>, configured_key: Option<&str>) -> Verification { + let mut findings = Findings(Vec::new()); + let root = Php::Array(bundle.clone()); + check_schema(bundle, &mut findings); + check_required_sections(bundle, &mut findings); + let memo_entries = check_run_references(&root, &mut findings); + check_payload_manifest(&root, &mut findings); + check_history_events(&root, &mut findings); + check_command_history_consistency(&root, &mut findings); + check_redaction(&root, &mut findings); + let mut unsigned = bundle.clone(); + unsigned.remove("integrity"); + let integrity = check_integrity(bundle.get("integrity"), &unsigned, signing_key.or(configured_key), &mut findings); + let summary_bundle = json!({ + "schema": string_value(bundle.get("schema")), + "schema_version": int_value(bundle.get("schema_version")), + "exported_at": string_value(bundle.get("exported_at")), + "dedupe_key": string_value(bundle.get("dedupe_key")), + "workflow_run_id": string_value(root.at(&["workflow", "run_id"])), + "workflow_instance_id": string_value(root.at(&["workflow", "instance_id"])), + "history_event_count": array(bundle.get("history_events")).map_or(0, PhpArray::len), + }); + let status = if findings.0.iter().any(|finding| finding["severity"] == ERROR) { + "failed" + } else if findings.0.iter().any(|finding| finding["severity"] == WARNING) { + "warning" + } else { + "ok" + }; + let mut by_rule: BTreeMap = BTreeMap::new(); + let mut counts = [0i64; 3]; + for finding in &findings.0 { + *by_rule.entry(finding["rule"].as_str().unwrap_or_default().to_owned()).or_default() += 1; + match finding["severity"].as_str() { + Some(ERROR) => counts[0] += 1, + Some(WARNING) => counts[1] += 1, + Some(INFO) => counts[2] += 1, + _ => {} + } + } + let by_rule = if by_rule.is_empty() { json!([]) } else { json!(by_rule) }; + Verification { + report: json!({ + "schema": "durable-workflow.v2.history-bundle-verification", + "schema_version": 1, + "status": status, + "bundle": summary_bundle, + "integrity": integrity, + "summary": {"errors":counts[0],"warnings":counts[1],"info":counts[2],"findings_by_rule":by_rule}, + "findings": findings.0, + }), + status, + memo_entries, + } +} + +fn check_schema(bundle: &PhpArray, findings: &mut Findings) { + match string_value(bundle.get("schema")) { + None => findings.error("bundle.schema_missing", "Bundle is missing the schema field.", "schema"), + Some(schema) if schema != SCHEMA => findings.add("bundle.schema_unexpected", ERROR, + format!("Bundle schema [{schema}] does not match expected [{SCHEMA}]."), Some("schema".into()), + json!({"expected":SCHEMA,"actual":schema})), + Some(_) => {} + } + match int_value(bundle.get("schema_version")) { + None => findings.error("bundle.schema_version_missing", "Bundle is missing schema_version.", "schema_version"), + Some(version) if version != SCHEMA_VERSION => findings.add("bundle.schema_version_unsupported", ERROR, + format!("Bundle schema_version {version} is not the v2 history-export structural marker ({SCHEMA_VERSION}); the v2 bundle shape is frozen and a different value indicates a parallel primitive that this verifier does not recognize."), + Some("schema_version".into()), json!({"expected":SCHEMA_VERSION,"actual":version})), + Some(_) => {} + } + if string_value(bundle.get("exported_at")).is_none() { + findings.add("bundle.exported_at_missing", WARNING, + "Bundle is missing exported_at; replay reports cannot be ordered without it.".into(), Some("exported_at".into()), json!({})); + } +} + +fn check_required_sections(bundle: &PhpArray, findings: &mut Findings) { + for (key, shape) in [ + ("workflow", "object"), ("payloads", "object"), ("history_events", "list"), ("commands", "list"), + ("signals", "list"), ("updates", "list"), ("tasks", "list"), ("activities", "list"), ("timers", "list"), + ("failures", "list"), ("links", "object"), ("redaction", "object"), ("codec_schemas", "object"), + ("payload_manifest", "object"), + ] { + match bundle.get(key) { + None => findings.error("bundle.section_missing", format!("Bundle is missing required section [{key}]."), key), + Some(value) if !value.is_array() => { + findings.error("bundle.section_invalid", format!("Bundle section [{key}] must be {}.", + if shape == "object" { "an object" } else { "a list" }), key); + } + Some(_) => {} + } + } +} + +/// `checkRunReferences`. Returns the decoded memo entries for the writer. +fn check_run_references(bundle: &Php, findings: &mut Findings) -> Option> { + let empty = Php::Array(PhpArray::default()); + let workflow = bundle.get("workflow").filter(|value| value.is_array()).unwrap_or(&empty); + if string_value(workflow.get("run_id")).is_none() { + findings.error("workflow.run_id_missing", "Bundle workflow.run_id is required.", "workflow.run_id"); + } + if string_value(workflow.get("instance_id")).is_none() { + findings.error("workflow.instance_id_missing", "Bundle workflow.instance_id is required.", "workflow.instance_id"); + } + if string_value(workflow.get("workflow_type")).is_none() { + findings.error("workflow.workflow_type_missing", "Bundle workflow.workflow_type is required.", "workflow.workflow_type"); + } + let redacted = |path: &str| { + array(bundle.at(&["redaction", "paths"])) + .is_some_and(|paths| paths.values().any(|value| value.str() == Some(path))) + }; + let (payload_redacted, projection_redacted) = (redacted("workflow.memo_payload"), redacted("workflow.memo")); + let mut entries = None; + match workflow.get("memo_payload") { + _ if payload_redacted => {} + Some(Php::Array(payload)) => match decode_entries(payload) { + Ok(decoded) => { + if !projection_redacted { + let projection = project_entries(&decoded); + if !workflow.get("memo").unwrap_or(&Php::Null).identical(&projection) { + findings.error("workflow.memo_projection_mismatch", + "Bundle workflow.memo does not match the JSON-safe projection of workflow.memo_payload.", "workflow.memo"); + } + } + entries = Some(decoded); + } + Err(message) => findings.error("workflow.memo_payload_invalid", + format!("Bundle workflow.memo_payload is not a valid portable Avro memo map: {message}"), "workflow.memo_payload"), + }, + _ => findings.error("workflow.memo_payload_missing", + "Bundle workflow.memo_payload must contain the lossless portable Avro memo authority.", "workflow.memo_payload"), + } + let last = int_value(workflow.get("last_history_sequence")); + let highest = array(bundle.get("history_events")).and_then(|events| { + events.values().filter(|event| event.is_array()).filter_map(|event| int_value(event.get("sequence"))).max() + }); + if let (Some(last), Some(highest)) = (last, highest) + && last < highest + { + findings.add("workflow.last_history_sequence_stale", WARNING, + format!("Workflow last_history_sequence ({last}) is below the highest recorded event sequence ({highest}); the bundle may be truncated."), + Some("workflow.last_history_sequence".into()), json!({"last_history_sequence":last,"highest_history_sequence":highest})); + } + entries +} + +fn check_payload_manifest(bundle: &Php, findings: &mut Findings) { + let Some(manifest) = array(bundle.get("payload_manifest")) else { + return; + }; + let schema = crate::codec::VALUE_SCHEMA.trim(); + let fingerprint = format!("crc64-avro:{VALUE_FINGERPRINT}"); + if let Some(entries) = array(manifest.get("entries")) { + for (index, entry) in &entries.0 { + let fallback = format!("payload_manifest.entries[{}]", index.text()); + if entry.array().is_none() { + findings.error("payload_manifest.entry_invalid", "Payload manifest entry must be an object.", fallback); + continue; + } + let path = string_value(entry.get("path")).map_or(fallback, str::to_owned); + check_manifest_entry(entry, &path, schema, &fingerprint, findings); + } + } + let Some(codec_schemas) = array(bundle.get("codec_schemas")) else { + return; + }; + let Some(avro) = array(codec_schemas.get("avro")) else { + return; + }; + let current = string_value(avro.get("current_fingerprint")); + let entry = current.and_then(|current| array(avro.get("writer_schemas")).and_then(|schemas| array(schemas.get(current)))); + match (current, entry) { + (Some(current), Some(entry)) if current == VALUE_FINGERPRINT => { + let same = |field: &str, expected: &str| entry.get(field).and_then(Php::str) == Some(expected); + if !same("schema", schema) || !same("fingerprint", &fingerprint) { + findings.error("codec_schemas.value_schema_drift", "Bundled Avro Value schema or fingerprint differs from this runtime.", + format!("codec_schemas.avro.writer_schemas.{current}")); + } + } + _ => findings.error("codec_schemas.value_schema_missing", "Avro codec metadata does not bundle the current Value writer schema.", + "codec_schemas.avro.writer_schemas"), + } +} + +/// One payload manifest entry's codec, availability and Avro framing checks, +/// in PHP's order. +fn check_manifest_entry(entry: &Php, path: &str, schema: &str, fingerprint: &str, findings: &mut Findings) { + let codec = string_value(entry.get("codec")); + let available = entry.get("available").is_some_and(Php::truthy); + let redacted = entry.get("redacted").is_some_and(Php::truthy); + let diagnostic = string_value(entry.get("diagnostic")); + if codec.is_none() { + findings.error("payload_manifest.codec_missing", format!("Payload manifest entry [{path}] is missing codec."), path.to_owned()); + } + if !redacted && available && diagnostic == Some("payload_missing") { + findings.error("payload_manifest.payload_missing", + format!("Manifest reports payload [{path}] available but bundle has no payload bytes."), path.to_owned()); + } + if codec == Some("avro") && available && !redacted && diagnostic != Some("external_storage_reference") { + if string_value(entry.get("avro_framing")).is_none() { + findings.add("payload_manifest.avro_framing_missing", WARNING, + format!("Avro payload [{path}] is missing framing metadata; offline decoders cannot pick a wrapper schema."), + Some(path.to_owned()), json!({})); + } + if let (Some(writer), Some(actual)) = + (string_value(entry.get("writer_schema")), string_value(entry.get("writer_schema_fingerprint"))) + { + let expected = (writer == schema).then(|| fingerprint.to_owned()); + if expected.as_deref() != Some(actual) { + findings.add("payload_manifest.writer_schema_fingerprint_mismatch", ERROR, + format!("Avro payload [{path}] writer_schema_fingerprint disagrees with the embedded schema; offline decoders will reject this entry."), + Some(path.to_owned()), json!({"expected":expected,"actual":actual})); + } + } + } +} + +fn check_history_events(bundle: &Php, findings: &mut Findings) { + let Some(events) = array(bundle.get("history_events")) else { + return; + }; + let mut previous: Option = None; + let mut seen: BTreeMap = BTreeMap::new(); + for (index, event) in &events.0 { + let path = format!("history_events[{}]", index.text()); + if !event.is_array() { + findings.error("history_events.entry_invalid", "History event entry must be an object.", path); + continue; + } + let sequence = int_value(event.get("sequence")); + match (sequence, previous) { + (None, _) => findings.error("history_events.sequence_missing", "History event is missing sequence.", path.clone()), + (Some(sequence), Some(previous)) if sequence <= previous => findings.add("history_events.sequence_not_monotonic", ERROR, + format!("History event sequence {sequence} does not advance past previous sequence {previous}."), Some(path.clone()), + json!({"sequence":sequence,"previous_sequence":previous})), + _ => {} + } + if sequence.is_some() { + previous = sequence; + } + if string_value(event.get("type")).is_none() { + findings.error("history_events.type_missing", "History event is missing type.", path.clone()); + } + if let Some(id) = string_value(event.get("id")) { + match seen.get(id) { + Some(first) => findings.add("history_events.id_duplicate", ERROR, format!("History event id [{id}] appears more than once."), + Some(path.clone()), json!({"id":id,"first_seen_at":first})), + None => { + seen.insert(id.to_owned(), path.clone()); + } + } + } + } +} + +fn check_command_history_consistency(bundle: &Php, findings: &mut Findings) { + let mut referenced: BTreeMap<&str, usize> = BTreeMap::new(); + if let Some(events) = array(bundle.get("history_events")) { + for event in events.values().filter(|event| event.is_array()) { + if let Some(command) = string_value(event.get("workflow_command_id")) { + *referenced.entry(command).or_default() += 1; + } + } + } + let Some(commands) = array(bundle.get("commands")) else { + return; + }; + for (index, command) in &commands.0 { + let path = format!("commands[{}]", index.text()); + if !command.is_array() { + findings.error("commands.entry_invalid", "Command row must be an object.", path); + continue; + } + let text = |field: &str| string_value(command.get(field)); + let Some(id) = text("id") else { + findings.error("commands.id_missing", "Command row is missing id.", path); + continue; + }; + let (status, outcome) = (text("status"), text("outcome")); + let settled = matches!(status, Some("applied" | "rejected")) || matches!(outcome, Some("applied" | "rejected")) + || text("applied_at").is_some() || text("rejected_at").is_some(); + if settled && !referenced.contains_key(id) { + findings.add("commands.history_event_missing", WARNING, + format!("Command [{id}] is settled (status={}, outcome={}) but no history event references it.", + status.unwrap_or("null"), outcome.unwrap_or("null")), + Some(path), json!({"command_id":id,"status":status,"outcome":outcome})); + } + } +} + +fn check_redaction(bundle: &Php, findings: &mut Findings) { + let Some(redaction) = array(bundle.get("redaction")) else { + return; + }; + let applied = redaction.get("applied").is_some_and(Php::truthy); + let empty = array(redaction.get("paths")).is_none_or(PhpArray::is_empty); + if applied && empty { + findings.add("redaction.empty_paths", INFO, + "Redaction is marked applied but no paths were redacted; replay-diff diagnostics will treat all payloads as in-scope.".into(), + Some("redaction.paths".into()), json!({})); + } +} + +/// `canonicalJson` of the bundle without its integrity block. +pub(super) fn canonical_json(unsigned: &PhpArray) -> String { + Php::Array(unsigned.clone()).canonical().encode(CANONICAL) +} + +pub(super) fn hex(bytes: &[u8]) -> String { + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +pub(super) fn hmac(canonical: &str, key: &str) -> String { + let mut mac = Hmac::::new_from_slice(key.as_bytes()).expect("HMAC takes any key length"); + mac.update(canonical.as_bytes()); + hex(&mac.finalize().into_bytes()) +} + +/// `hash_equals`. +fn equal(left: &str, right: &str) -> bool { + use subtle::ConstantTimeEq; + left.len() == right.len() && bool::from(left.as_bytes().ct_eq(right.as_bytes())) +} + +fn check_integrity(raw: Option<&Php>, unsigned: &PhpArray, key: Option<&str>, findings: &mut Findings) -> Value { + let mut report = json!({"present":false,"canonicalization":null,"checksum_algorithm":null,"expected_checksum":null, + "recomputed_checksum":null,"checksum_matches":null,"signature_algorithm":null,"signature_present":false, + "signature_verified":null,"key_id":null}); + let Some(raw) = raw.filter(|raw| raw.is_array()) else { + findings.error("integrity.missing", "Bundle has no integrity block; offline replay verification cannot vouch for its contents.", "integrity"); + return report; + }; + let text = |field: &str| string_value(raw.get(field)).map(str::to_owned); + let canonicalization = text("canonicalization"); + let algorithm = text("checksum_algorithm"); + let expected = text("checksum"); + let signature_algorithm = text("signature_algorithm"); + let signature = text("signature"); + report["present"] = json!(true); + report["canonicalization"] = json!(canonicalization); + report["checksum_algorithm"] = json!(algorithm); + report["expected_checksum"] = json!(expected); + report["signature_algorithm"] = json!(signature_algorithm); + report["key_id"] = json!(text("key_id")); + report["signature_present"] = json!(signature.is_some()); + if canonicalization.as_deref() != Some("json-recursive-ksort-v1") { + findings.error("integrity.canonicalization_unsupported", format!( + "Unsupported canonicalization [{}]; only json-recursive-ksort-v1 is recognized offline.", + canonicalization.as_deref().unwrap_or("null")), "integrity.canonicalization"); + return report; + } + if algorithm.as_deref() != Some("sha256") { + findings.error("integrity.checksum_algorithm_unsupported", format!( + "Unsupported checksum algorithm [{}]; only sha256 is recognized.", algorithm.as_deref().unwrap_or("null")), + "integrity.checksum_algorithm"); + return report; + } + let canonical = canonical_json(unsigned); + let recomputed = hex(&Sha256::digest(canonical.as_bytes())); + report["recomputed_checksum"] = json!(recomputed); + match &expected { + None => { + findings.error("integrity.checksum_missing", "Integrity block is missing checksum.", "integrity.checksum"); + report["checksum_matches"] = json!(false); + } + Some(expected) => { + let matches = equal(expected, &recomputed); + report["checksum_matches"] = json!(matches); + if !matches { + findings.add("integrity.checksum_mismatch", ERROR, + "Recomputed bundle checksum does not match the recorded checksum; the bundle has been altered after export.".into(), + Some("integrity.checksum".into()), json!({"expected":expected,"actual":recomputed})); + } + } + } + if signature_algorithm.is_none() && signature.is_none() { + return report; + } + if signature_algorithm.as_deref() != Some("hmac-sha256") { + findings.error("integrity.signature_algorithm_unsupported", format!( + "Unsupported signature algorithm [{}]; only hmac-sha256 is recognized.", signature_algorithm.as_deref().unwrap_or("null")), + "integrity.signature_algorithm"); + return report; + } + let Some(signature) = signature else { + findings.error("integrity.signature_missing", "Integrity block declares a signature_algorithm but carries no signature.", "integrity.signature"); + return report; + }; + let Some(key) = key else { + findings.add("integrity.signature_key_unavailable", WARNING, + "Bundle is signed but no verification key is configured; signature was not verified.".into(), + Some("integrity.signature".into()), json!({})); + return report; + }; + let verified = equal(&hmac(&canonical, key), &signature); + report["signature_verified"] = json!(verified); + if !verified { + findings.add("integrity.signature_mismatch", ERROR, + "Bundle signature does not match the configured key; treat the bundle as untrusted.".into(), + Some("integrity.signature".into()), json!({"key_id":report["key_id"]})); + } + report +} + +/// A value `Avro::unserialize` returns for the fixed Value schema: PHP +/// scalars, bytes, lists, string-keyed maps, and the map adapter PHP uses +/// for an empty map or one with integer-like keys. +#[derive(Clone, Debug)] +pub(super) enum Decoded { + Null, + Bool(bool), + Long(i64), + Double(f64), + Bytes(Vec), + Str(String), + List(Vec), + Map(Vec<(String, Decoded)>), + MapAdapter(Vec<(String, Decoded)>), +} + +impl Decoded { + /// The protocol value, for re-encoding the canonical envelope. + pub(super) fn protocol(&self) -> crate::codec::Value { + use crate::codec::Value as Wire; + match self { + Self::Null => Wire::Null, + Self::Bool(flag) => Wire::Boolean(*flag), + Self::Long(number) => Wire::Long(*number), + Self::Double(number) => Wire::Double(*number), + Self::Bytes(bytes) => Wire::Bytes(bytes.clone()), + Self::Str(text) => Wire::String(text.clone()), + Self::List(items) => Wire::Array(items.iter().map(Self::protocol).collect()), + Self::Map(pairs) | Self::MapAdapter(pairs) => { + Wire::Map(pairs.iter().map(|(key, value)| (key.clone(), value.protocol())).collect()) + } + } + } + + /// `AvroValueJsonProjection::project`. + pub(super) fn project(&self) -> Php { + let list = |items: Vec| Php::Array(PhpArray(items.into_iter().enumerate().map(|(index, value)| (Key::Int(index as i64), value)).collect())); + let map = |pairs: Vec<(&str, Php)>| Php::Array(PhpArray(pairs.into_iter().map(|(key, value)| (Key::of(key), value)).collect())); + match self { + Self::Null => Php::Null, + Self::Bool(flag) => Php::Bool(*flag), + Self::Long(number) => Php::Int(*number), + Self::Double(number) => Php::Float(*number), + Self::Str(text) => Php::Str(text.clone()), + Self::Bytes(bytes) => map(vec![("$type", Php::Str("bytes".into())), ("base64", Php::Str(STANDARD.encode(bytes)))]), + Self::List(items) => list(items.iter().map(Self::project).collect()), + Self::Map(pairs) => map(pairs.iter().map(|(key, value)| (key.as_str(), value.project())).collect()), + Self::MapAdapter(pairs) => map(vec![ + ("$type", Php::Str("map".into())), + ("entries", list(pairs.iter().map(|(key, value)| map(vec![("key", Php::Str(key.clone())), ("value", value.project())])).collect())), + ]), + } + } +} + +/// The JSON-safe projection of decoded memo entries. +pub(super) fn project_entries(entries: &[(String, Decoded)]) -> Php { + Php::Array(PhpArray(entries.iter().map(|(key, value)| (Key::of(key), value.project())).collect())) +} + +/// `MemoPayload::decodeEntries`: the entries of a `{codec, blob}` memo map, +/// or PHP's exception message. +pub(super) fn decode_entries(envelope: &PhpArray) -> Result, String> { + let mut keys: Vec = envelope.0.iter().map(|(key, _)| key.text()).collect(); + keys.sort(); + let keys_are_strings = envelope.0.iter().all(|(key, _)| matches!(key, Key::Str(_))); + if !keys_are_strings || keys != ["blob", "codec"] { + return Err("invalid_memo_payload: expected exactly the standard {codec, blob} payload envelope.".into()); + } + match envelope.get("codec") { + Some(Php::Str(codec)) if codec.is_empty() || codec == "avro" => {} + Some(Php::Str(codec)) => return Err(format!("unsupported_payload_codec: payload codec \"{codec}\" is not supported by Durable Workflow 2.0; use codec=\"avro\" with the fixed Avro Value schema and single-object framing. JSON remains the HTTP document transport, not a workflow payload codec.")), + _ => return Err("unsupported_payload_codec: memo payloads require codec \"avro\".".into()), + } + let Some(blob) = envelope.get("blob").and_then(Php::str).filter(|blob| !blob.is_empty()) else { + return Err("invalid_memo_payload: the Avro envelope blob must be a non-empty string.".into()); + }; + match unserialize(blob)? { + // The decoder already merged repeated keys. + Decoded::MapAdapter(pairs) => match pairs.iter().find(|(key, _)| !memo_key(key)) { + Some((key, _)) => Err(format!("invalid_memo_key: memo key \"{key}\" does not match the portable key pattern {MEMO_KEY_PATTERN}.")), + None => Ok(pairs), + }, + Decoded::Map(pairs) => Ok(pairs), + _ => Err("invalid_memo_entries_payload: the Avro memo payload must decode to a string-keyed map.".into()), + } +} + +fn memo_key(key: &str) -> bool { + let digits = key.strip_prefix('-').unwrap_or(key); + let integer_like = !digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit()); + !integer_like && (1..=64).contains(&key.len()) && key.bytes().all(|byte| byte.is_ascii_alphanumeric() || b"_.:-".contains(&byte)) +} + +fn codec_error(detail: &str, remediation: &str) -> String { + format!("{detail} Remediation: {remediation}") +} + +/// `Avro::unserialize` for one single-object frame of the Value schema, in +/// the frame's map order. +pub(super) fn unserialize(blob: &str) -> Result { + let malformed = || codec_error("invalid_payload_framing: malformed Avro Value datum.", + "Provide one complete datum encoded with the writer schema selected by the single-object fingerprint."); + let Ok(bytes) = crate::codec::php_base64(blob) else { + let first = blob.chars().next().unwrap_or(' '); + return Err(if matches!(first, '{' | '[' | '"' | '-' | 't' | 'f' | 'n' | '0'..='9') { + codec_error("invalid_payload_framing: payload bytes look like JSON, not base64-encoded Avro.", + "Encode the value with the fixed Avro Value codec; JSON is only the HTTP document transport.") + } else { + codec_error("invalid_payload_framing: failed to base64-decode Avro payload bytes.", + "Avro payloads must be strict base64 containing a c301 single-object frame.") + }); + }; + if bytes.len() < 10 || !bytes.starts_with(&[0xc3, 0x01]) { + return Err(codec_error("invalid_payload_framing: expected Avro single-object magic c301.", + "Encode the value with the Durable Workflow 2.0 fixed Avro Value schema and single-object framing; JSON is only the HTTP document transport.")); + } + let fingerprint = hex(&bytes[2..10]); + if fingerprint != VALUE_FINGERPRINT { + return Err(codec_error(&format!("unsupported_payload_schema: unknown CRC-64-AVRO fingerprint {fingerprint}."), + "Upgrade to a runtime that bundles this writer schema; never guess or fall back to JSON.")); + } + let mut reader = Reader { bytes: &bytes[10..], at: 0, truncated: false }; + let value = reader.value(0); + if reader.truncated { + return Err(codec_error("invalid_payload_framing: truncated Avro Value datum.", + "Provide one complete Avro Value datum after the single-object fingerprint.")); + } + let value = value.ok_or_else(malformed)?; + if reader.at != reader.bytes.len() { + return Err(codec_error("invalid_payload_framing: trailing bytes after Avro Value datum.", + "Provide exactly one Avro Value datum in each single-object frame.")); + } + Ok(value) +} + +struct Reader<'a> { + bytes: &'a [u8], + at: usize, + truncated: bool, +} + +impl Reader<'_> { + fn take(&mut self, count: usize) -> Option<&[u8]> { + if self.bytes.len() - self.at < count { + self.truncated = true; + return None; + } + let slice = &self.bytes[self.at..self.at + count]; + self.at += count; + Some(slice) + } + + /// A zig-zag varint of at most ten bytes within int64. + fn long(&mut self) -> Option { + let mut raw: u64 = 0; + for index in 0..10 { + let byte = self.take(1)?[0]; + if index == 9 && byte > 1 { + return None; + } + raw |= u64::from(byte & 0x7f) << (7 * index); + if byte & 0x80 == 0 { + return Some((raw >> 1) as i64 ^ -((raw & 1) as i64)); + } + } + None + } + + fn length(&mut self) -> Option { + usize::try_from(self.long()?).ok() + } + + fn string(&mut self) -> Option { + let length = self.length()?; + String::from_utf8(self.take(length)?.to_vec()).ok() + } + + /// Each block of items: a count (negative counts carry a byte size). + fn blocks(&mut self, depth: usize, mut item: impl FnMut(&mut Self, usize) -> Option<()>) -> Option<()> { + loop { + let mut count = self.long()?; + if count == 0 { + return Some(()); + } + if count < 0 { + count = count.checked_neg()?; + self.long()?; + } + for _ in 0..count { + item(self, depth + 1)?; + } + } + } + + fn value(&mut self, depth: usize) -> Option { + if depth > 512 { + return None; + } + Some(match self.long()? { + 0 => Decoded::Null, + 1 => match self.take(1)?[0] { + 0 => Decoded::Bool(false), + 1 => Decoded::Bool(true), + _ => return None, + }, + 2 => Decoded::Long(self.long()?), + 3 => { + let value = f64::from_le_bytes(self.take(8)?.try_into().ok()?); + if !value.is_finite() { + return None; + } + Decoded::Double(value) + } + 4 => { + let length = self.length()?; + Decoded::Bytes(self.take(length)?.to_vec()) + } + 5 => Decoded::Str(self.string()?), + 6 => { + let mut items = Vec::new(); + self.blocks(depth, |reader, depth| { + items.push(reader.value(depth)?); + Some(()) + })?; + Decoded::List(items) + } + 7 => { + // Apache's reader assigns `$items[$key] = $value`: a + // repeated key keeps its first position. + let mut pairs: Vec<(String, Decoded)> = Vec::new(); + let mut positions: std::collections::HashMap = std::collections::HashMap::new(); + self.blocks(depth, |reader, depth| { + let key = reader.string()?; + let value = reader.value(depth)?; + let key = Key::of(&key).text(); + match positions.get(&key) { + Some(&index) => pairs[index].1 = value, + None => { + positions.insert(key.clone(), pairs.len()); + pairs.push((key, value)); + } + } + Some(()) + })?; + if pairs.is_empty() || pairs.iter().any(|(key, _)| matches!(Key::of(key), Key::Int(_))) { + Decoded::MapAdapter(pairs) + } else { + Decoded::Map(pairs) + } + } + _ => return None, + }) + } +} + +/// `json_encode` of a memo projection as the Server stores it: with +/// JSON_PRESERVE_ZERO_FRACTION. +pub(super) fn stored_projection(value: &Decoded) -> Value { + serde_json::from_str(&value.project().encode(Flags { zero_fraction: true, ..Flags::default() })).unwrap_or(Value::Null) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bundle(text: &str) -> PhpArray { + super::super::php_value::request_body(text.as_bytes()) + } + + #[test] + fn an_empty_body_reports_php_s_twenty_two_findings() { + let verification = verify(&bundle(r#"{"dry_run":true}"#), None, None); + let rules: Vec<&str> = verification.report["findings"].as_array().unwrap().iter().map(|f| f["rule"].as_str().unwrap()).collect(); + assert_eq!(rules.len(), 22); + assert_eq!(&rules[..3], ["bundle.schema_missing", "bundle.schema_version_missing", "bundle.exported_at_missing"]); + assert_eq!(&rules[17..], ["workflow.run_id_missing", "workflow.instance_id_missing", "workflow.workflow_type_missing", + "workflow.memo_payload_missing", "integrity.missing"]); + assert_eq!(verification.status, "failed"); + assert_eq!(verification.report["summary"]["findings_by_rule"]["bundle.section_missing"], 14); + } + + #[test] + fn memo_payloads_decode_in_frame_order_and_project() { + // {"n": 3, "note": "λ rich"} from a published export. + let entries = decode_entries(&bundle(r#"{"codec":"avro","blob":"wwHioz3/VYAiNw4EAm4EBghub3RlCg7OuyByaWNoAA=="}"#)).unwrap(); + assert_eq!(entries.iter().map(|(key, _)| key.as_str()).collect::>(), ["n", "note"]); + assert_eq!(project_entries(&entries).encode(Flags::default()), r#"{"n":3,"note":"\u03bb rich"}"#); + assert!(decode_entries(&bundle(r#"{"codec":"avro"}"#)).unwrap_err().starts_with("invalid_memo_payload")); + assert!(decode_entries(&bundle(r#"{"codec":"json","blob":"x"}"#)).unwrap_err().starts_with("unsupported_payload_codec: payload codec \"json\"")); + assert!(unserialize("{}").unwrap_err().contains("look like JSON")); + assert!(unserialize("AAAA").unwrap_err().contains("magic c301")); + } + + #[test] + fn a_repeated_map_key_keeps_its_first_position() { + // {"a": 1, "b": 2, "a": 3} as one Avro Value map block. + let mut frame = vec![0xc3, 0x01, 0xe2, 0xa3, 0x3d, 0xff, 0x55, 0x80, 0x22, 0x37]; + frame.extend([0x0e, 0x06, 0x02, b'a', 0x04, 0x02, 0x02, b'b', 0x04, 0x04, 0x02, b'a', 0x04, 0x06, 0x00]); + let Decoded::Map(pairs) = unserialize(&STANDARD.encode(frame)).unwrap() else { + panic!("a string-keyed map"); + }; + let pairs: Vec<_> = pairs.iter().map(|(key, value)| (key.as_str(), if let Decoded::Long(n) = value { *n } else { -1 })).collect(); + assert_eq!(pairs, [("a", 3), ("b", 2)]); + } +} diff --git a/rust/src/runtime/control_plane.rs b/rust/src/runtime/control_plane.rs index 7d0bfdb6d..8467f7058 100644 --- a/rust/src/runtime/control_plane.rs +++ b/rust/src/runtime/control_plane.rs @@ -20,6 +20,7 @@ pub(super) enum Operation<'a> { Update(&'a str), Signal(&'a str), Query(&'a str), + ImportEmbeddedV2, } impl Operation<'_> { @@ -136,6 +137,8 @@ impl Operation<'_> { &["workflow_id", "operation_name", "operation_name_field"], &["outcome"], ), + // PHP defines no contract for the import, so its defaults apply. + Self::ImportEmbeddedV2 => ("import_embedded_v2", &[], &[]), }; let mut metadata = json!({"schema":"durable-workflow.v2.control-plane-response", "version":1, "operation":operation, "workflow_id":body.get("workflow_id"), @@ -301,7 +304,7 @@ impl Operation<'_> { selected_run_not_current configured_workflow_type_invalid v1_projection_read_only \ backend_lock_pressure control_plane_internal_error", ), - Self::List | Self::ListRuns | Self::Describe | Self::Observe | Self::Debug => return None, + Self::List | Self::ListRuns | Self::Describe | Self::Observe | Self::Debug | Self::ImportEmbeddedV2 => return None, }) } } @@ -315,6 +318,7 @@ pub(super) async fn admission_response(request: Request, mut body: Value) -> Val let path = request.uri().path().to_owned(); let segments: Vec<_> = path.trim_start_matches('/').split('/').collect(); let operation = match (request.method().as_str(), segments.as_slice()) { + ("POST", ["api", "workflows", "import", "embedded-v2"]) => Operation::ImportEmbeddedV2, ("POST", ["api", "workflows"]) => Operation::Start, ("POST", ["api", "workflows", _, "signal", name]) | ("POST", ["api", "workflows", _, "runs", _, "signal", name]) => Operation::Signal(name), diff --git a/rust/src/runtime/execution.rs b/rust/src/runtime/execution.rs index 4b7819678..bf16f1b1a 100644 --- a/rust/src/runtime/execution.rs +++ b/rust/src/runtime/execution.rs @@ -1578,6 +1578,16 @@ impl Runtime { delegate!(heartbeat_activity(task_id: &str, body: Value, protocol: &str) -> Result); delegate!(activity_status(task_id: &str, body: Value, protocol: &str) -> Result); delegate!(renew_activity_lease(task_id: &str, body: Value) -> Result); + delegate!(import_existing_run(run_id: &str) -> Result>); + delegate!(import_write(plan: &super::workflow_import::Plan) -> std::result::Result); + /// The namespace this runtime's store serves. + pub(crate) fn namespace_name(&self) -> String { + match &*self.storage { + Storage::Sqlite(store) => store.namespace_name(), + Storage::Postgres(store) => store.namespace_name(), + Storage::MySql(store) => store.namespace_name(), + } + } delegate!(acknowledge_activity_cancellation(task_id: &str, body: Value, protocol: &str) -> Result); pub(crate) async fn fail_activity(&self, task_id: &str, body: Value) -> Result { let (failure, blob) = diff --git a/rust/src/runtime/external_resolution.rs b/rust/src/runtime/external_resolution.rs index d847fc2ef..eb6b762f6 100644 --- a/rust/src/runtime/external_resolution.rs +++ b/rust/src/runtime/external_resolution.rs @@ -37,6 +37,23 @@ impl PreparedExternal { } } +/// `WorkflowController::oversizedPayloadPreview` over a stored command +/// payload: an external reference, native's or one PHP stored, whose size is +/// above `max_payload_bytes`. Only the small reference is read. +pub(super) fn oversized_reference(payload: &str) -> bool { + use base64::{Engine, engine::general_purpose::STANDARD}; + let size = if let Some(json) = payload.strip_prefix(STORED_PREFIX) { + serde_json::from_str::(json).ok().and_then(|reference| reference["size_bytes"].as_u64()) + } else if let Some(encoded) = payload.strip_prefix(super::history_presentation::PHP_EXTERNAL_REFERENCE) { + STANDARD.decode(encoded).ok() + .and_then(|json| serde_json::from_slice::(&json).ok()) + .and_then(|envelope| envelope["external_storage"]["size_bytes"].as_u64()) + } else { + None + }; + size.is_some_and(|size| size > super::payload_limits::max_payload_bytes() as u64) +} + pub(super) fn stored_envelope(blob: &str) -> Option { let value = serde_json::from_str(blob.strip_prefix(STORED_PREFIX)?).ok()?; let reference = Reference::validate(&value).ok()?; diff --git a/rust/src/runtime/history_export.rs b/rust/src/runtime/history_export.rs index 7cccf3989..8a0238d3a 100644 --- a/rust/src/runtime/history_export.rs +++ b/rust/src/runtime/history_export.rs @@ -89,7 +89,7 @@ pub(super) const COMMANDS: &[(&str, Column)] = &[ ("payload", Text), ("source", Text), ("request_id", Text), ("status", Text), ("outcome", Text), ("rejection_reason", Text), ("accepted_at", Time), ("applied_at", Time), ("rejected_at", Time), ]; -pub(super) const COMMANDS_SQL: &str = "SELECT c.*,COALESCE(s.signal_name,u.update_name) AS target_name FROM workflow_commands c LEFT JOIN workflow_signal_records s ON s.workflow_command_id=c.id LEFT JOIN workflow_updates u ON u.workflow_command_id=c.id WHERE c.workflow_run_id=$1 ORDER BY c.command_sequence,c.created_at,c.id"; +pub(super) const COMMANDS_SQL: &str = "SELECT c.*,COALESCE(s.signal_name,u.update_name) AS target_name FROM workflow_commands c LEFT JOIN workflow_signal_records s ON s.workflow_command_id=c.id AND s.workflow_instance_id=c.workflow_instance_id LEFT JOIN workflow_updates u ON u.workflow_command_id=c.id AND u.workflow_instance_id=c.workflow_instance_id WHERE c.workflow_run_id=$1 ORDER BY c.command_sequence,c.created_at,c.id"; pub(super) const SIGNALS: &[(&str, Column)] = &[ ("id", Text), ("workflow_command_id", Text), ("command_sequence", Int), ("workflow_sequence", Int), ("signal_name", Text), ("signal_wait_id", Text), ("target_scope", Text), ("requested_workflow_run_id", Text), ("resolved_workflow_run_id", Text), @@ -136,8 +136,11 @@ pub(super) const LINKS: &[(&str, Column)] = &[ ("child_workflow_instance_id", Text), ("child_workflow_run_id", Text), ("is_primary_parent", Flag), ("created_at", Time), ("run_number", Int), ("workflow_type", Text), ("workflow_class", Text), ("status", Text), ("closed_reason", Text), ]; -pub(super) const PARENTS_SQL: &str = "SELECT l.*,CASE WHEN l.is_primary_parent THEN 1 ELSE 0 END AS is_primary_parent_flag,r.run_number,r.workflow_type,r.workflow_class,r.status,r.closed_reason FROM workflow_links l LEFT JOIN workflow_runs r ON r.id=l.parent_workflow_run_id WHERE l.child_workflow_run_id=$1 ORDER BY l.created_at,l.id"; -pub(super) const CHILDREN_SQL: &str = "SELECT l.*,CASE WHEN l.is_primary_parent THEN 1 ELSE 0 END AS is_primary_parent_flag,r.run_number,r.workflow_type,r.workflow_class,r.status,r.closed_reason FROM workflow_links l LEFT JOIN workflow_runs r ON r.id=l.child_workflow_run_id WHERE l.parent_workflow_run_id=$1 AND l.link_type='continue_as_new' ORDER BY l.created_at,l.id"; +// A related run of another namespace is never exported, whatever a link +// row says (Server #765); a command's signal or update must share its +// instance. +pub(super) const PARENTS_SQL: &str = "SELECT l.*,CASE WHEN l.is_primary_parent THEN 1 ELSE 0 END AS is_primary_parent_flag,r.run_number,r.workflow_type,r.workflow_class,r.status,r.closed_reason FROM workflow_links l LEFT JOIN workflow_runs r ON r.id=l.parent_workflow_run_id WHERE l.child_workflow_run_id=$1 AND (r.id IS NULL OR r.namespace=(SELECT m.namespace FROM workflow_runs m WHERE m.id=l.child_workflow_run_id)) ORDER BY l.created_at,l.id"; +pub(super) const CHILDREN_SQL: &str = "SELECT l.*,CASE WHEN l.is_primary_parent THEN 1 ELSE 0 END AS is_primary_parent_flag,r.run_number,r.workflow_type,r.workflow_class,r.status,r.closed_reason FROM workflow_links l LEFT JOIN workflow_runs r ON r.id=l.child_workflow_run_id WHERE l.parent_workflow_run_id=$1 AND l.link_type='continue_as_new' AND (r.id IS NULL OR r.namespace=(SELECT m.namespace FROM workflow_runs m WHERE m.id=l.parent_workflow_run_id)) ORDER BY l.created_at,l.id"; /// The rows `forRun` reads for one run. #[derive(Default)] diff --git a/rust/src/runtime/http.rs b/rust/src/runtime/http.rs index 85c37bff6..23fcc6c81 100644 --- a/rust/src/runtime/http.rs +++ b/rust/src/runtime/http.rs @@ -67,6 +67,7 @@ pub fn router(runtime: Runtime) -> Router { delete(delete_search_attribute), ) .route("/api/workflows", get(list_workflows).post(start)) + .route("/api/workflows/import/embedded-v2", post(import_embedded_v2)) .route("/api/activities", get(list_activities).post(start_activity)) .route("/api/activities/{activity_id}", get(show_activity)) .route( @@ -458,6 +459,8 @@ async fn guard(State(runtime): State, mut request: Request, next: Next) || path.starts_with("/api/system/") || path == "/api/runtime-credentials" || path.starts_with("/api/runtime-credentials/") + // History import writes runs into the store; PHP serves it to admins. + || path.starts_with("/api/workflows/import/") { &["admin"] } else { @@ -705,7 +708,8 @@ fn exact_namespace_route(path: &str) -> Option { "/api/workflows" | "/api/activities" | "/api/search-attributes" => READ_WRITE, // The metrics describe the namespace's own state, read by GET or POST. "/api/system/metrics" => READ_WRITE, - "/api/external-payloads/v1" | "/api/storage/test" => WRITE, + // An import writes into the requested namespace. + "/api/external-payloads/v1" | "/api/storage/test" | "/api/workflows/import/embedded-v2" => WRITE, "/api/task-queues" | "/api/deployments" | "/api/workers" @@ -1092,6 +1096,21 @@ async fn start( Ok((status, Json(super::control_plane::Operation::Start.response(result)))) } +/// `POST /api/workflows/import/embedded-v2`. The controller reads the body +/// and the query string itself, as Laravel's request input. +async fn import_embedded_v2( + Extension(runtime): Extension, + uri: axum::http::Uri, + body: axum::body::Bytes, +) -> Result<(StatusCode, Json)> { + let query: Vec<(String, String)> = uri + .query() + .map(|query| form_urlencoded::parse(query.as_bytes()).into_owned().collect()) + .unwrap_or_default(); + let (status, answer) = runtime.import_embedded_v2(&body, &query).await?; + Ok((status, Json(answer))) +} + /// `POST /api/activities`: a standalone activity in its own host run. async fn start_activity( Extension(runtime): Extension, diff --git a/rust/src/runtime/mod.rs b/rust/src/runtime/mod.rs index 82d9c7382..8f444e3d6 100644 --- a/rust/src/runtime/mod.rs +++ b/rust/src/runtime/mod.rs @@ -36,6 +36,7 @@ mod activity_results; mod activity_summary; mod all_groups; mod backend; +mod bundle_verifier; mod cancellation; mod cancellation_cascade; mod child_summary; @@ -81,6 +82,7 @@ mod operator_metrics; mod payload_cleanup; mod payload_discovery; mod payload_limits; +mod php_value; #[cfg(debug_assertions)] #[doc(hidden)] pub mod poll_pauses; @@ -109,6 +111,7 @@ mod task_queue_build_ids; mod task_queues; mod query_route; mod run_describe; +mod workflow_import; mod workflow_streams; mod schedule_list; mod schedule_storage; diff --git a/rust/src/runtime/php_value.rs b/rust/src/runtime/php_value.rs new file mode 100644 index 000000000..6a574c41f --- /dev/null +++ b/rust/src/runtime/php_value.rs @@ -0,0 +1,650 @@ +//! PHP's view of a decoded JSON request: `json_decode($body, true)` gives +//! ordered arrays whose canonical decimal string keys become integers, with +//! integers beyond int64 read as floats. Laravel's TrimStrings and +//! ConvertEmptyStringsToNull then rewrite every string. The history import +//! verifies, checks and stores exactly that document, so it keeps PHP's +//! order, key types and int/float distinction rather than serde's maps. +use super::laravel_validation::laravel_trim; + +#[derive(Clone, Debug)] +pub(super) enum Php { + Null, + Bool(bool), + Int(i64), + Float(f64), + Str(String), + Array(PhpArray), +} + +/// A PHP array key: a canonical decimal integer string is an integer. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(super) enum Key { + Int(i64), + Str(String), +} + +impl Key { + /// `ZEND_HANDLE_NUMERIC_STR`: `0` or an optionally negative decimal + /// without leading zeros that fits in int64 is an integer key. + pub(super) fn of(text: &str) -> Self { + let digits = text.strip_prefix('-').unwrap_or(text); + let canonical = text == "0" + || (!digits.is_empty() && !digits.starts_with('0') && digits.bytes().all(|byte| byte.is_ascii_digit())); + match canonical.then(|| text.parse::().ok()).flatten() { + Some(number) => Self::Int(number), + None => Self::Str(text.to_owned()), + } + } + + pub(super) fn text(&self) -> String { + match self { + Self::Int(number) => number.to_string(), + Self::Str(text) => text.clone(), + } + } +} + +/// An ordered PHP array. +#[derive(Clone, Debug, Default)] +pub(super) struct PhpArray(pub(super) Vec<(Key, Php)>); + +impl PhpArray { + pub(super) fn get(&self, key: &str) -> Option<&Php> { + let key = Key::of(key); + self.0.iter().find(|(candidate, _)| *candidate == key).map(|(_, value)| value) + } + + pub(super) fn contains(&self, key: &str) -> bool { + self.get(key).is_some() + } + + /// `$array[$key] = $value`: an existing key keeps its position. + pub(super) fn insert(&mut self, key: Key, value: Php) { + match self.0.iter_mut().find(|(candidate, _)| *candidate == key) { + Some((_, slot)) => *slot = value, + None => self.0.push((key, value)), + } + } + + pub(super) fn remove(&mut self, key: &str) { + let key = Key::of(key); + self.0.retain(|(candidate, _)| *candidate != key); + } + + /// `array_is_list`. + pub(super) fn is_list(&self) -> bool { + self.0.iter().enumerate().all(|(index, (key, _))| *key == Key::Int(index as i64)) + } + + pub(super) fn len(&self) -> usize { + self.0.len() + } + + pub(super) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub(super) fn values(&self) -> impl Iterator { + self.0.iter().map(|(_, value)| value) + } +} + +impl Php { + pub(super) fn array(&self) -> Option<&PhpArray> { + match self { + Self::Array(array) => Some(array), + _ => None, + } + } + + pub(super) fn str(&self) -> Option<&str> { + match self { + Self::Str(text) => Some(text), + _ => None, + } + } + + /// `$value[$key] ?? null` for the named keys PHP code uses: a missing + /// key, or a value that is not an array, reads as absent. + pub(super) fn get(&self, key: &str) -> Option<&Php> { + self.array().and_then(|array| array.get(key)) + } + + pub(super) fn at(&self, path: &[&str]) -> Option<&Php> { + path.iter().try_fold(self, |value, key| value.get(key)) + } + + pub(super) fn is_array(&self) -> bool { + matches!(self, Self::Array(_)) + } + + /// PHP's `(bool)` cast. + pub(super) fn truthy(&self) -> bool { + match self { + Self::Null => false, + Self::Bool(flag) => *flag, + Self::Int(number) => *number != 0, + Self::Float(number) => *number != 0.0, + Self::Str(text) => !(text.is_empty() || text == "0"), + Self::Array(array) => !array.is_empty(), + } + } + + /// `get_debug_type`. + pub(super) fn debug_type(&self) -> &'static str { + match self { + Self::Null => "null", + Self::Bool(_) => "bool", + Self::Int(_) => "int", + Self::Float(_) => "float", + Self::Str(_) => "string", + Self::Array(_) => "array", + } + } + + /// Laravel's global TrimStrings and ConvertEmptyStringsToNull, applied to + /// every string at any depth (no nested key is excepted). + pub(super) fn normalize(&mut self) { + match self { + Self::Str(text) => { + let trimmed = laravel_trim(text); + if trimmed.is_empty() { + *self = Self::Null; + } else if trimmed.len() != text.len() { + *text = trimmed.to_owned(); + } + } + Self::Array(array) => array.0.iter_mut().for_each(|(_, value)| value.normalize()), + _ => {} + } + } + + /// PHP's `===`: the same type and value, arrays with the same keys in + /// the same order. + pub(super) fn identical(&self, other: &Self) -> bool { + match (self, other) { + (Self::Null, Self::Null) => true, + (Self::Bool(left), Self::Bool(right)) => left == right, + (Self::Int(left), Self::Int(right)) => left == right, + (Self::Float(left), Self::Float(right)) => left == right, + (Self::Str(left), Self::Str(right)) => left == right, + (Self::Array(left), Self::Array(right)) => { + left.len() == right.len() + && left.0.iter().zip(&right.0).all(|((lk, lv), (rk, rv))| lk == rk && lv.identical(rv)) + } + _ => false, + } + } + + /// `json_encode` with the given flags. + pub(super) fn encode(&self, flags: Flags) -> String { + let mut out = String::new(); + self.write(&mut out, flags); + out + } + + fn write(&self, out: &mut String, flags: Flags) { + match self { + Self::Null => out.push_str("null"), + Self::Bool(flag) => out.push_str(if *flag { "true" } else { "false" }), + Self::Int(number) => out.push_str(&number.to_string()), + Self::Float(number) => out.push_str(&float(*number, flags.zero_fraction)), + Self::Str(text) => string(out, text, flags), + Self::Array(array) if array.is_list() => { + out.push('['); + for (index, value) in array.values().enumerate() { + if index > 0 { + out.push(','); + } + value.write(out, flags); + } + out.push(']'); + } + Self::Array(array) => { + out.push('{'); + for (index, (key, value)) in array.0.iter().enumerate() { + if index > 0 { + out.push(','); + } + string(out, &key.text(), flags); + out.push(':'); + value.write(out, flags); + } + out.push('}'); + } + } + } + + /// `BundleIntegrityVerifier::canonicalize`: lists keep their order, + /// other arrays are key-sorted as strings, recursively. + pub(super) fn canonical(&self) -> Self { + match self { + Self::Array(array) if array.is_list() => { + Self::Array(PhpArray(array.0.iter().map(|(key, value)| (key.clone(), value.canonical())).collect())) + } + Self::Array(array) => { + let mut entries: Vec<(Key, Php)> = + array.0.iter().map(|(key, value)| (key.clone(), value.canonical())).collect(); + entries.sort_by(|(left, _), (right, _)| left.text().cmp(&right.text())); + Self::Array(PhpArray(entries)) + } + other => other.clone(), + } + } + + /// The document PHP stores and reads back for this value through a JSON + /// column (`json_encode` without flags, then `json_decode`). + pub(super) fn to_json(&self) -> serde_json::Value { + serde_json::from_str(&self.encode(Flags::default())).unwrap_or(serde_json::Value::Null) + } +} + +/// `json_encode` options. The default escapes slashes and non-ASCII text +/// and prints integral floats without a fraction, as PHP does. +#[derive(Clone, Copy, Default)] +pub(super) struct Flags { + pub(super) unescaped_slashes: bool, + pub(super) unescaped_unicode: bool, + pub(super) zero_fraction: bool, +} + +/// `JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION`, the history +/// bundle's canonical encoding. +pub(super) const CANONICAL: Flags = Flags { unescaped_slashes: true, unescaped_unicode: false, zero_fraction: true }; + +/// PHP's float output under `serialize_precision = -1`: the shortest digits +/// that read back exactly, in fixed notation unless the decimal point falls +/// before four leading zeros or after fifteen digits; `.0` is added to an +/// integral fixed value only with JSON_PRESERVE_ZERO_FRACTION. +pub(super) fn float(value: f64, zero_fraction: bool) -> String { + if value == 0.0 { + let sign = if value.is_sign_negative() { "-" } else { "" }; + return format!("{sign}0{}", if zero_fraction { ".0" } else { "" }); + } + let scientific = format!("{:e}", value.abs()); + let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0")); + let digits = mantissa.replace('.', ""); + let exponent: i32 = exponent.parse().unwrap_or(0); + let point = exponent + 1; + let sign = if value.is_sign_negative() { "-" } else { "" }; + if point < -3 || point > 15 { + let mantissa = if digits.len() == 1 { format!("{digits}.0") } else { format!("{}.{}", &digits[..1], &digits[1..]) }; + format!("{sign}{mantissa}e{}{}", if exponent < 0 { '-' } else { '+' }, exponent.abs()) + } else if point <= 0 { + format!("{sign}0.{}{digits}", "0".repeat((-point) as usize)) + } else if digits.len() as i32 <= point { + let fraction = if zero_fraction { ".0" } else { "" }; + format!("{sign}{digits}{}{fraction}", "0".repeat((point - digits.len() as i32) as usize)) + } else { + format!("{sign}{}.{}", &digits[..point as usize], &digits[point as usize..]) + } +} + +fn string(out: &mut String, text: &str, flags: Flags) { + out.push('"'); + for ch in text.chars() { + match ch { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '/' if !flags.unescaped_slashes => out.push_str("\\/"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + ch if (ch as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", ch as u32)), + // Line and paragraph separators stay escaped without + // JSON_UNESCAPED_LINE_TERMINATORS. + '\u{2028}' | '\u{2029}' => out.push_str(&format!("\\u{:04x}", ch as u32)), + ch if (ch as u32) < 0x80 || flags.unescaped_unicode => out.push(ch), + ch => { + let mut units = [0u16; 2]; + for unit in ch.encode_utf16(&mut units) { + out.push_str(&format!("\\u{unit:04x}")); + } + } + } + } + out.push('"'); +} + +/// `json_decode($text, true)` with PHP's number and key rules. Floats are +/// read with Rust's correctly rounded parser, as PHP's `zend_strtod` reads +/// them. `None` for text that is not one JSON document. +pub(super) fn decode(text: &[u8]) -> Option { + let mut parser = Parser { bytes: text, at: 0, depth: 0 }; + let value = parser.value()?; + parser.space(); + (parser.at == parser.bytes.len()).then_some(value) +} + +struct Parser<'a> { + bytes: &'a [u8], + at: usize, + depth: usize, +} + +impl Parser<'_> { + fn space(&mut self) { + while matches!(self.bytes.get(self.at), Some(b' ' | b'\t' | b'\n' | b'\r')) { + self.at += 1; + } + } + + fn literal(&mut self, word: &[u8], value: Php) -> Option { + self.bytes[self.at..].starts_with(word).then(|| { + self.at += word.len(); + value + }) + } + + fn value(&mut self) -> Option { + self.space(); + match *self.bytes.get(self.at)? { + b'n' => self.literal(b"null", Php::Null), + b't' => self.literal(b"true", Php::Bool(true)), + b'f' => self.literal(b"false", Php::Bool(false)), + b'"' => self.string().map(Php::Str), + b'[' => self.nested(Self::json_list), + b'{' => self.nested(Self::json_object), + _ => self.number(), + } + } + + /// A JSON array as a PHP list, keys 0..n. + fn json_list(parser: &mut Self) -> Option { + let mut array = PhpArray::default(); + parser.space(); + if parser.bytes.get(parser.at) == Some(&b']') { + parser.at += 1; + return Some(array); + } + loop { + let value = parser.value()?; + array.0.push((Key::Int(array.0.len() as i64), value)); + parser.space(); + match parser.bytes.get(parser.at)? { + b',' => parser.at += 1, + b']' => { + parser.at += 1; + return Some(array); + } + _ => return None, + } + } + } + + /// A JSON object as a PHP array, keeping each key's first position. + fn json_object(parser: &mut Self) -> Option { + let mut array = PhpArray::default(); + // Each key's position, so a large object parses in linear time. + let mut positions: std::collections::HashMap = std::collections::HashMap::new(); + parser.space(); + if parser.bytes.get(parser.at) == Some(&b'}') { + parser.at += 1; + return Some(array); + } + loop { + parser.space(); + if parser.bytes.get(parser.at) != Some(&b'"') { + return None; + } + let key = parser.string()?; + parser.space(); + if parser.bytes.get(parser.at) != Some(&b':') { + return None; + } + parser.at += 1; + let value = parser.value()?; + // A repeated key replaces the earlier value in place. + let key = Key::of(&key); + match positions.get(&key) { + Some(&index) => array.0[index].1 = value, + None => { + positions.insert(key.clone(), array.0.len()); + array.0.push((key, value)); + } + } + parser.space(); + match parser.bytes.get(parser.at)? { + b',' => parser.at += 1, + b'}' => { + parser.at += 1; + return Some(array); + } + _ => return None, + } + } + } + + /// PHP's default decoding depth is 512. + fn nested(&mut self, body: impl FnOnce(&mut Self) -> Option) -> Option { + self.depth += 1; + if self.depth > 512 { + return None; + } + self.at += 1; + let array = body(self)?; + self.depth -= 1; + Some(Php::Array(array)) + } + + fn number(&mut self) -> Option { + let start = self.at; + let digits = |parser: &mut Self| { + let begin = parser.at; + while parser.bytes.get(parser.at).is_some_and(u8::is_ascii_digit) { + parser.at += 1; + } + parser.at > begin + }; + if self.bytes.get(self.at) == Some(&b'-') { + self.at += 1; + } + let first = *self.bytes.get(self.at)?; + if first == b'0' { + self.at += 1; + } else if !digits(self) { + return None; + } + let mut integral = true; + if self.bytes.get(self.at) == Some(&b'.') { + self.at += 1; + integral = false; + if !digits(self) { + return None; + } + } + if matches!(self.bytes.get(self.at), Some(b'e' | b'E')) { + self.at += 1; + integral = false; + if matches!(self.bytes.get(self.at), Some(b'+' | b'-')) { + self.at += 1; + } + if !digits(self) { + return None; + } + } + let text = std::str::from_utf8(&self.bytes[start..self.at]).ok()?; + // An integer beyond int64 decodes as a float. + if integral && let Ok(number) = text.parse::() { + return Some(Php::Int(number)); + } + text.parse::().ok().filter(|value| value.is_finite()).map(Php::Float) + } + + fn hex(&mut self) -> Option { + let text = std::str::from_utf8(self.bytes.get(self.at..self.at + 4)?).ok()?; + self.at += 4; + u32::from_str_radix(text, 16).ok() + } + + fn string(&mut self) -> Option { + self.at += 1; + let mut out = String::new(); + loop { + let start = self.at; + while !matches!(self.bytes.get(self.at), Some(b'"' | b'\\') | None) { + if self.bytes[self.at] < 0x20 { + return None; + } + self.at += 1; + } + out.push_str(std::str::from_utf8(&self.bytes[start..self.at]).ok()?); + match *self.bytes.get(self.at)? { + b'"' => { + self.at += 1; + return Some(out); + } + _ => { + self.at += 1; + let escape = *self.bytes.get(self.at)?; + self.at += 1; + match escape { + b'"' => out.push('"'), + b'\\' => out.push('\\'), + b'/' => out.push('/'), + b'b' => out.push('\u{8}'), + b'f' => out.push('\u{c}'), + b'n' => out.push('\n'), + b'r' => out.push('\r'), + b't' => out.push('\t'), + b'u' => { + let unit = self.hex()?; + let code = if (0xd800..0xdc00).contains(&unit) { + // A high surrogate needs its low half. + if self.bytes.get(self.at..self.at + 2) != Some(&b"\\u"[..]) { + return None; + } + self.at += 2; + let low = self.hex()?; + if !(0xdc00..0xe000).contains(&low) { + return None; + } + 0x10000 + ((unit - 0xd800) << 10) + (low - 0xdc00) + } else if (0xdc00..0xe000).contains(&unit) { + return None; + } else { + unit + }; + out.push(char::from_u32(code)?); + } + _ => return None, + } + } + } + } + } +} + +/// Laravel's `$request->json()`: the body decoded as a PHP array (a scalar +/// body is cast to a one-element array), then normalized. +pub(super) fn request_body(bytes: &[u8]) -> PhpArray { + let decoded = if bytes.iter().all(u8::is_ascii_whitespace) { + Php::Array(PhpArray::default()) + } else { + decode(bytes).unwrap_or(Php::Null) + }; + let mut body = match decoded { + Php::Array(array) => array, + Php::Null => PhpArray::default(), + scalar => PhpArray(vec![(Key::Int(0), scalar)]), + }; + body.0.iter_mut().for_each(|(_, value)| value.normalize()); + body +} + +/// PHP's `is_numeric` for a string: optional leading whitespace, a sign, +/// digits with an optional fraction and exponent, optional trailing +/// whitespace. +pub(super) fn numeric(text: &str) -> Option { + let trimmed = text.trim_start_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']).trim_end_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']); + let body = trimmed.strip_prefix(['+', '-']).unwrap_or(trimmed); + let (mantissa, exponent) = match body.find(['e', 'E']) { + Some(at) => (&body[..at], Some(&body[at + 1..])), + None => (body, None), + }; + let (whole, fraction) = mantissa.split_once('.').unwrap_or((mantissa, "")); + let digits = |part: &str| part.bytes().all(|byte| byte.is_ascii_digit()); + if (whole.is_empty() && fraction.is_empty()) || !digits(whole) || !digits(fraction) { + return None; + } + if let Some(exponent) = exponent { + let exponent = exponent.strip_prefix(['+', '-']).unwrap_or(exponent); + if exponent.is_empty() || !digits(exponent) { + return None; + } + } + trimmed.parse::().ok() +} + +/// PHP's `(int)` cast of a numeric string. +pub(super) fn int_cast(text: &str) -> Option { + let value = numeric(text)?; + let trimmed = text.trim_matches([' ', '\t', '\n', '\r', '\x0b', '\x0c']); + if let Ok(number) = trimmed.strip_prefix('+').unwrap_or(trimmed).parse::() { + return Some(number); + } + // Out-of-range or fractional values truncate; PHP 8 saturates neither, + // and an out-of-range float cast is undefined, so refuse it. + (value.is_finite() && value.abs() < 9.2e18).then(|| value.trunc() as i64) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn decode(text: &str) -> Php { + super::decode(text.as_bytes()).unwrap() + } + + #[test] + fn decoding_keeps_php_key_types_order_and_numbers() { + let value = decode(r#"{"b":1,"0":2,"a":{"x":1},"b":3,"01":4,"-0":5,"-7":6,"big":18446744073709551615}"#); + let array = value.array().unwrap(); + let keys: Vec = array.0.iter().map(|(key, _)| key.clone()).collect(); + assert_eq!(keys, [Key::Str("b".into()), Key::Int(0), Key::Str("a".into()), Key::Str("01".into()), + Key::Str("-0".into()), Key::Int(-7), Key::Str("big".into())]); + assert!(matches!(array.get("b"), Some(Php::Int(3)))); + assert!(matches!(array.get("big"), Some(Php::Float(_)))); + assert!(decode("[1,2]").array().unwrap().is_list()); + assert!(decode(r#"{"0":1,"1":2}"#).array().unwrap().is_list()); + assert!(!decode(r#"{"1":1,"0":2}"#).array().unwrap().is_list()); + } + + #[test] + fn canonical_encoding_matches_php() { + // Published PHP printed {"k":"\u03bb/\u00e9","f":1.0,...} for these values under + // JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION, keys in insertion order + // (canonicalization sorts them). + let value = decode(r#"{"k":"λ/é","f":1.0,"e":1e25,"o":{},"n":["a","b"],"big":18446744073709551615}"#); + assert_eq!(value.encode(CANONICAL), + r#"{"k":"\u03bb/\u00e9","f":1.0,"e":1.0e+25,"o":[],"n":["a","b"],"big":1.8446744073709552e+19}"#); + assert_eq!(value.canonical().encode(CANONICAL), + r#"{"big":1.8446744073709552e+19,"e":1.0e+25,"f":1.0,"k":"\u03bb/\u00e9","n":["a","b"],"o":[]}"#); + // Sorted keys 0..n-1 encode as a list; string order puts "10" first. + assert_eq!(decode(r#"{"1":"a","0":"b"}"#).canonical().encode(CANONICAL), r#"["b","a"]"#); + assert_eq!(decode(r#"{"x":{"2":1,"10":2}}"#).canonical().encode(CANONICAL), r#"{"x":{"10":2,"2":1}}"#); + // Published PHP printed "a\/b\u0001\u2028\ud83d\ude00" for this value. + assert_eq!(decode(r#""a/b\u0001\u2028\ud83d\ude00""#).encode(Flags::default()), r#""a\/b\u0001\u2028\ud83d\ude00""#); + assert_eq!(float(1.0, false), "1"); + assert_eq!(float(-0.0, true), "-0.0"); + assert_eq!(float(0.1, false), "0.1"); + assert_eq!(float(1e-7, false), "1.0e-7"); + } + + #[test] + fn requests_are_normalized_like_laravel() { + let body = request_body(br#"{"a":" x ","b":" ","c":["",{"d":"\u00a0y"}],"e":5}"#); + assert_eq!(Php::Array(body).encode(Flags::default()), r#"{"a":"x","b":null,"c":[null,{"d":"y"}],"e":5}"#); + assert_eq!(Php::Array(request_body(b"\"x\"")).encode(Flags::default()), r#"["x"]"#); + assert!(request_body(b"null").is_empty()); + assert!(Php::Int(1).identical(&Php::Int(1)) && !Php::Int(1).identical(&Php::Float(1.0))); + assert!(!decode(r#"{"a":1,"b":2}"#).identical(&decode(r#"{"b":2,"a":1}"#))); + assert_eq!(numeric(" 12 "), Some(12.0)); + assert_eq!(numeric("1e3"), Some(1000.0)); + assert_eq!(numeric("1e"), None); + assert_eq!(numeric("."), None); + assert_eq!(int_cast("12.9"), Some(12)); + } +} diff --git a/rust/src/runtime/run_describe.rs b/rust/src/runtime/run_describe.rs index c18bf74d1..6320efecf 100644 --- a/rust/src/runtime/run_describe.rs +++ b/rust/src/runtime/run_describe.rs @@ -222,6 +222,25 @@ where Ok(updates) } + /// A run's compatibility marker and whether the live fleet supports it, + /// as describe and history report them. + pub(super) async fn compatibility_fields(&self, run: &DB::Row) -> Result> { + let compatibility = DB::optional_string(run, "compatibility")?; + let queue = DB::optional_string(run, "queue")?; + let snapshots = self.fleet_snapshots(now()).await?; + let (supported, fleet_reason) = super::run_diagnostics::fleet_supports( + &snapshots, + compatibility.as_deref().map(str::trim).filter(|value| !value.is_empty()), + queue.as_deref(), + ); + Ok(Map::from_iter([ + ("compatibility".into(), json!(compatibility)), + ("compatibility_status".into(), json!(if supported { "compatible" } else { "no_compatible_worker" })), + ("compatibility_supported_in_fleet".into(), json!(supported)), + ("compatibility_fleet_reason".into(), json!(fleet_reason)), + ])) + } + /// The fields `formatRun` adds for a described run row. pub(super) async fn describe_fields( &self, @@ -249,14 +268,7 @@ where Some(row) => (DB::optional_string(&row, "wait_kind")?, DB::optional_string(&row, "wait_reason")?), None => (None, None), }; - let compatibility = DB::optional_string(run, "compatibility")?; - let queue = DB::optional_string(run, "queue")?; - let snapshots = self.fleet_snapshots(now()).await?; - let (supported, fleet_reason) = super::run_diagnostics::fleet_supports( - &snapshots, - compatibility.as_deref().map(str::trim).filter(|value| !value.is_empty()), - queue.as_deref(), - ); + let compatibility = self.compatibility_fields(run).await?; // `canServeQuery`: failed, cancelled and terminated runs refuse // queries; others need a registered route. let query_servable = current @@ -272,10 +284,7 @@ where if started["recovery_kind"] == "redrive" && started["continued_from_run_id"].is_string() { fields.insert("continued_from_run_id".into(), started["continued_from_run_id"].clone()); } - fields.insert("compatibility".into(), json!(compatibility)); - fields.insert("compatibility_status".into(), json!(if supported { "compatible" } else { "no_compatible_worker" })); - fields.insert("compatibility_supported_in_fleet".into(), json!(supported)); - fields.insert("compatibility_fleet_reason".into(), json!(fleet_reason)); + fields.extend(compatibility); // `ServerWorkflowControlPlane::withDefinitionRouting`. let mut tx = self.begin_read().await?; let routing = self.definition_inspect(&mut tx, run, None).await?; diff --git a/rust/src/runtime/store.rs b/rust/src/runtime/store.rs index 28e77fd0c..964fd1135 100644 --- a/rust/src/runtime/store.rs +++ b/rust/src/runtime/store.rs @@ -53,13 +53,23 @@ const TASK_SNAPSHOT_EVENTS: &[&str] = &[ /// in the command row (`c.payload`). Other commands keep no stored reference. /// Only a stored reference is read; inline payloads are not. macro_rules! run_commands_sql { - ($signal:literal, $start:literal) => { + ($signal:literal, $start:literal, $php:literal) => { concat!( "SELECT c.id,c.command_sequence,c.command_type,c.target_scope,c.requested_workflow_run_id,c.resolved_workflow_run_id,c.source,c.status,c.outcome,c.rejection_reason,c.workflow_type,c.workflow_class,c.accepted_at,c.applied_at,c.rejected_at,c.context,s.signal_name,CASE WHEN c.command_type='signal' AND ", $signal, " THEN s.arguments WHEN c.command_type='start' AND ", $start, - " THEN c.payload END AS stored_payload FROM workflow_commands c LEFT JOIN workflow_signal_records s ON s.workflow_command_id=c.id WHERE c.workflow_run_id=$1 AND c.workflow_instance_id=$2 ORDER BY c.command_sequence,c.created_at,c.id" + " THEN c.payload END AS stored_payload,CASE WHEN ", + $php, + // An imported PHP history keeps PHP's own external references. + " THEN c.payload END AS php_payload_reference,", + // `commandReason()` reads only small inline payloads of commands + // that were not rejected; references are never read for a reason. + "CASE WHEN c.status<>'rejected' AND (c.payload_codec IS NULL OR c.payload_codec='avro') AND LENGTH(c.payload)<=8192 AND NOT (", + $start, + ") AND NOT (", + $php, + ") THEN c.payload END AS reason_payload FROM workflow_commands c LEFT JOIN workflow_signal_records s ON s.workflow_command_id=c.id AND s.workflow_instance_id=c.workflow_instance_id WHERE c.workflow_run_id=$1 AND c.workflow_instance_id=$2 ORDER BY c.command_sequence,c.created_at,c.id" ) }; } @@ -644,13 +654,17 @@ where let command_rows = Self::query(DB::RUN_COMMANDS_SQL) .bind(DB::string(&row, "id")?).bind(workflow_id).fetch_all(&self.pool).await?; let mut commands = Vec::with_capacity(command_rows.len()); + let mut reason_codec = None; for command in command_rows { // `oversizedPayloadPreview` over the stored reference's envelope; // an omitted preview also omits the target name, as PHP does. let omitted = DB::optional_string(&command, "stored_payload")? .as_deref() .and_then(super::external_resolution::stored_envelope) - .is_some_and(|envelope| super::run_describe::oversized(&envelope)); + .is_some_and(|envelope| super::run_describe::oversized(&envelope)) + || DB::optional_string(&command, "php_payload_reference")? + .as_deref() + .is_some_and(super::external_resolution::oversized_reference); let target_name = DB::optional_string(&command, "signal_name")?.filter(|_| !omitted); let mut public = json!({"id":DB::string(&command,"id")?,"sequence":DB::optional_number(&command,"command_sequence")?, "type":DB::string(&command,"command_type")?,"target_scope":DB::string(&command,"target_scope")?, @@ -669,6 +683,18 @@ where if let Some(context) = DB::optional_document_row(&command, "context")? { fields.extend(super::command_context::described(&context)); } + // `commandReason()`: a command that was not rejected shows the + // reason its payload records. Only small inline payloads are read; + // every writer caps the reason at 1000 characters. + if let Some(payload) = DB::optional_string(&command, "reason_payload")? { + let codec = reason_codec.get_or_insert_with(|| crate::codec::ValueCodec::new().ok()); + if let Some(Ok(crate::codec::Value::Map(map))) = codec.as_ref().map(|codec| codec.decode(&payload)) + && let Some(crate::codec::Value::String(reason)) = map.get("reason") + && !reason.is_empty() + { + fields.insert("reason".into(), json!(reason)); + } + } fields.retain(|_, value| !value.is_null()); commands.push(public); } @@ -712,6 +738,11 @@ where page_size: i64, ) -> Result { self.describe(workflow_id, Some(run_id)).await?; + let run = Self::query("SELECT compatibility,queue FROM workflow_runs WHERE id=$1") + .bind(run_id) + .fetch_one(&self.pool) + .await?; + let compatibility = self.compatibility_fields(&run).await?; let (events, next) = Self::history_page(&self.pool, run_id, after_sequence, page_size).await?; let public_events: Vec = events @@ -722,9 +753,11 @@ where "principal": super::history_presentation::principal(&e["payload"]), "payload": e["payload"]}) }) .collect(); - Ok( - json!({"workflow_id": workflow_id, "run_id": run_id, "events": public_events, "next_page_token": next}), - ) + // `HistoryController::show` reports the run's compatibility as + // describe does. + let mut body = json!({"workflow_id": workflow_id, "run_id": run_id, "events": public_events, "next_page_token": next}); + body.as_object_mut().expect("history object").extend(compatibility); + Ok(body) } pub(crate) async fn signal( @@ -1282,8 +1315,10 @@ where return Ok(()); } let signal_id = text(&payload, "workflow_signal_id")?; - let signal = Self::query("SELECT s.*,c.message_sequence FROM workflow_signal_records s JOIN workflow_commands c ON c.id=s.workflow_command_id WHERE s.id=$1 AND s.workflow_run_id=$2") - .bind(signal_id).bind(run_id).fetch_one(&mut **tx).await?; + // The signal's own command, never one of another workflow (Server #765). + let signal = Self::query("SELECT s.*,c.message_sequence FROM workflow_signal_records s JOIN workflow_commands c ON c.id=s.workflow_command_id AND c.workflow_instance_id=s.workflow_instance_id WHERE s.id=$1 AND s.workflow_run_id=$2") + .bind(signal_id).bind(run_id).fetch_optional(&mut **tx).await? + .ok_or_else(|| refuse(StatusCode::CONFLICT, "signal_command_unavailable"))?; if DB::string(&signal, "status")? != "received" { return Err(refuse(StatusCode::CONFLICT, "signal_already_applied")); } @@ -1328,9 +1363,10 @@ where .await?; let command_id = DB::string(&signal, "workflow_command_id")?; let applied_at = now(); - Self::query("UPDATE workflow_commands SET applied_at=$1 WHERE id=$2") + Self::query("UPDATE workflow_commands SET applied_at=$1 WHERE id=$2 AND workflow_instance_id=$3") .bind(DB::bind_time(applied_at)) .bind(&command_id) + .bind(DB::string(&signal, "workflow_instance_id")?) .execute(&mut **tx) .await?; // PHP's `applySignal` takes a sequence only from the signal's own @@ -3889,11 +3925,11 @@ where }; let command_type = DB::optional_string(&row, "command_type")?; let target_name = match command_type.as_deref() { - Some("signal") => Self::query("SELECT signal_name FROM workflow_signal_records WHERE workflow_command_id=$1") - .bind(command_id).fetch_optional(&mut **tx).await? + Some("signal") => Self::query("SELECT signal_name FROM workflow_signal_records WHERE workflow_command_id=$1 AND workflow_instance_id=$2") + .bind(command_id).bind(DB::string(&row, "workflow_instance_id")?).fetch_optional(&mut **tx).await? .map(|record| DB::optional_string(&record, "signal_name")).transpose()?.flatten(), - Some("update") => Self::query("SELECT update_name FROM workflow_updates WHERE workflow_command_id=$1") - .bind(command_id).fetch_optional(&mut **tx).await? + Some("update") => Self::query("SELECT update_name FROM workflow_updates WHERE workflow_command_id=$1 AND workflow_instance_id=$2") + .bind(command_id).bind(DB::string(&row, "workflow_instance_id")?).fetch_optional(&mut **tx).await? .map(|record| DB::optional_string(&record, "update_name")).transpose()?.flatten(), _ => None, }; diff --git a/rust/src/runtime/store_record_tests.rs b/rust/src/runtime/store_record_tests.rs index 9529c9473..8431e2745 100644 --- a/rust/src/runtime/store_record_tests.rs +++ b/rust/src/runtime/store_record_tests.rs @@ -3,6 +3,7 @@ use super::Store; use crate::runtime::backend::Backend; use crate::runtime::external_resolution::STORED_PREFIX; +use crate::runtime::history_presentation::PHP_EXTERNAL_REFERENCE; use serde_json::{Value, json}; use std::collections::BTreeMap; @@ -23,6 +24,12 @@ fn described_commands_read_only_the_exact_native_stored_prefix() { let expected = format!("CASE WHEN c.command_type='signal' AND {signal}{quoted} THEN s.arguments \ WHEN c.command_type='start' AND {start}{quoted} THEN c.payload END"); assert!(sql.contains(&expected), "{backend}: {sql}"); + // An imported PHP reference is matched by its exact 23-byte prefix, + // and neither kind of reference is ever read as a command reason. + let php = format!("{}'{PHP_EXTERNAL_REFERENCE}'", start.replace(",1,30)", ",1,23)")); + assert!(sql.contains(&format!("CASE WHEN {php} THEN c.payload END AS php_payload_reference")), "{backend}: {sql}"); + assert!(sql.contains(&format!("AND NOT ({start}{quoted}) AND NOT ({php}) THEN c.payload END AS reason_payload")), + "{backend}: {sql}"); assert!(!sql.contains("LIKE"), "{backend}: {sql}"); } } @@ -128,7 +135,8 @@ async fn described_commands_select_no_inline_payload() { .await .unwrap(); if let Some(arguments) = arguments { - Store::::query("INSERT INTO workflow_signal_records(id,workflow_command_id,signal_name,status,arguments) VALUES ($1,$2,'s','received',$3)") + // A signal record shares its command's instance, as every writer stores it. + Store::::query("INSERT INTO workflow_signal_records(id,workflow_command_id,workflow_instance_id,signal_name,status,arguments) VALUES ($1,$2,'instance','s','received',$3)") .bind(format!("s-{id}")) .bind(*id) .bind(*arguments) diff --git a/rust/src/runtime/terminal_summary.rs b/rust/src/runtime/terminal_summary.rs index de6c5c065..91c70f8f7 100644 --- a/rust/src/runtime/terminal_summary.rs +++ b/rust/src/runtime/terminal_summary.rs @@ -34,11 +34,13 @@ where .await?; let status = DB::string(&run, "status")?; let closed = terminal(&status); - // Retention and imported histories need their separately qualified + // Retention and v1 projections need their separately qualified // counters. Never label a partial scan as a complete stored summary. - if DB::optional_instant(&run, "details_pruned_at")?.is_some() - || DB::optional_string(&run, "import_source")?.is_some() - { + // An embedded v2 import is a complete v2 history, projected as PHP's + // `RunSummaryProjector` projects it, from the imported rows. + let import_source = DB::optional_string(&run, "import_source")?; + let embedded_import = import_source.as_deref() == Some(super::workflow_import::IMPORT_SOURCE); + if DB::optional_instant(&run, "details_pruned_at")?.is_some() || (import_source.is_some() && !embedded_import) { return Ok(()); } // A state outside the qualified producer must not retain an earlier @@ -130,6 +132,13 @@ where if let Some(active) = active { Self::write_active_summary_fields(tx, run_id, &active).await?; } + if embedded_import { + Self::query("UPDATE workflow_run_summaries SET engine_source=$1 WHERE id=$2") + .bind(super::workflow_import::ENGINE_SOURCE) + .bind(run_id) + .execute(&mut **tx) + .await?; + } Ok(()) } pub(super) async fn workflow_history_budget( diff --git a/rust/src/runtime/updates.rs b/rust/src/runtime/updates.rs index 7a78a7c1b..8699d4ed1 100644 --- a/rust/src/runtime/updates.rs +++ b/rust/src/runtime/updates.rs @@ -146,7 +146,8 @@ fn update_argument_errors(contract: &Value, name: &str, values: &[crate::codec:: } Value::Object(errors) } -const UPDATE_ROW: &str = "SELECT u.*,c.workflow_type,c.message_sequence,c.context AS command_context FROM workflow_updates u JOIN workflow_commands c ON c.id=u.workflow_command_id WHERE u.id=$1"; +// An update reads only its own workflow's command (Server #765). +const UPDATE_ROW: &str = "SELECT u.*,c.workflow_type,c.message_sequence,c.context AS command_context FROM workflow_updates u JOIN workflow_commands c ON c.id=u.workflow_command_id AND c.workflow_instance_id=u.workflow_instance_id WHERE u.id=$1"; #[derive(Clone, Copy)] enum UpdateLookup { @@ -414,7 +415,7 @@ where return Ok((status, validation_response(failure, wait_for, context.as_ref(), name))); } let existing = if let Some(request_id) = request_id { - Self::query("SELECT u.id,u.update_name,u.workflow_run_id FROM workflow_updates u JOIN workflow_commands c ON c.id=u.workflow_command_id WHERE c.workflow_instance_id=$1 AND c.command_type='update' AND c.request_id=$2") + Self::query("SELECT u.id,u.update_name,u.workflow_run_id FROM workflow_updates u JOIN workflow_commands c ON c.id=u.workflow_command_id AND c.workflow_instance_id=u.workflow_instance_id WHERE c.workflow_instance_id=$1 AND c.command_type='update' AND c.request_id=$2") .bind(workflow_id).bind(request_id).fetch_optional(&mut *tx).await? } else { None @@ -1046,8 +1047,9 @@ where let run_id = DB::string(run, "id")?; let update = Self::query(UPDATE_ROW) .bind(update_id) - .fetch_one(&mut **tx) - .await?; + .fetch_optional(&mut **tx) + .await? + .ok_or_else(|| refuse(StatusCode::CONFLICT, "update_command_unavailable"))?; if DB::string(&update, "workflow_run_id")? != run_id || DB::string(&update, "status")? != "accepted" { @@ -1123,10 +1125,11 @@ where .bind(sequence).bind(&failure_id).bind(failure.message).bind(DB::bind_time(closed_at)) .bind(DB::bind_time(closed_at)).bind(update_id).execute(&mut **tx).await?; Self::query( - "UPDATE workflow_commands SET outcome='update_failed',applied_at=$1 WHERE id=$2", + "UPDATE workflow_commands SET outcome='update_failed',applied_at=$1 WHERE id=$2 AND workflow_instance_id=$3", ) .bind(DB::bind_time(closed_at)) .bind(&command_id) + .bind(&workflow_id) .execute(&mut **tx) .await?; } else { @@ -1157,10 +1160,11 @@ where Self::query("UPDATE workflow_updates SET status='completed',outcome='update_completed',workflow_sequence=$1,result=$2,applied_at=$3,closed_at=$4 WHERE id=$5") .bind(sequence).bind(result).bind(DB::bind_time(applied_at)).bind(DB::bind_time(applied_at)).bind(update_id).execute(&mut **tx).await?; Self::query( - "UPDATE workflow_commands SET outcome='update_completed',applied_at=$1 WHERE id=$2", + "UPDATE workflow_commands SET outcome='update_completed',applied_at=$1 WHERE id=$2 AND workflow_instance_id=$3", ) .bind(DB::bind_time(applied_at)) .bind(&command_id) + .bind(&workflow_id) .execute(&mut **tx) .await?; } diff --git a/rust/src/runtime/workflow_import.rs b/rust/src/runtime/workflow_import.rs new file mode 100644 index 000000000..8c036d5a5 --- /dev/null +++ b/rust/src/runtime/workflow_import.rs @@ -0,0 +1,1701 @@ +//! `POST /api/workflows/import/embedded-v2`, as published Server 2.11.1 +//! serves it (`EmbeddedV2ImportController` over Workflow 2.10.2's +//! `EmbeddedV2HistoryImport`): an embedded application's history-export +//! bundle is verified, checked for eligibility, and written in one +//! transaction into the request's namespace. The answer is the import +//! report: 201 imported, 200 already_imported or dry_run, 422 otherwise. +//! +//! The bundle is read as PHP reads it: the JSON body decoded into ordered +//! PHP arrays and normalized by Laravel's TrimStrings and +//! ConvertEmptyStringsToNull, so the checksum, the dedupe key and every +//! stored value see the same document PHP's importer sees. +//! +//! Published defects are not copied (Server #765). PHP looks the run and the +//! instance up in every namespace, so a bundle imported into another +//! namespace answers already_imported for a namespace that holds no such +//! run, and an import attaches its run to another namespace's instance and +//! takes over its current run. Native counts a run as already imported only +//! in the requested namespace (elsewhere it is a run_id conflict), and +//! refuses to write into an instance of another namespace (a failed write, +//! as PHP itself reports when the run number collides). PHP also copies +//! every row reference unchecked, so a bundle can point its signals and +//! updates at another run's commands, its tasks at another run's +//! activities, and its lineage at another namespace's runs; native refuses +//! such a bundle (`reject_foreign_references`, `check_store_references`) +//! and links only runs of one namespace. And native reads the signing key +//! only from the body, never from the URL. +use super::bundle_verifier::{self, Decoded}; +use super::execution::Runtime; +use super::php_value::{self, Flags, Key, Php, PhpArray}; +use super::store::{id, now}; +use super::{Result, refuse}; +use axum::http::StatusCode; +use chrono::{DateTime, NaiveDateTime, Utc}; +use serde_json::{Map, Value, json}; +use std::collections::{HashMap, HashSet}; + +pub(super) const IMPORT_SOURCE: &str = "embedded_v2"; +pub(super) const ENGINE_SOURCE: &str = "embedded_v2_import"; +const REPORT_SCHEMA: &str = "durable-workflow.v2.embedded-v2-import.report"; +const CONTRACT_VERSION: i64 = 2; +const SOURCE_RUNTIME: &str = "embedded"; + +const RUN_STATUSES: &[&str] = &["pending", "running", "waiting", "cancelled", "terminated", "completed", "failed"]; +const TERMINAL_STATUSES: &[&str] = &["cancelled", "terminated", "completed", "failed"]; +const HISTORY_EVENT_TYPES: &[&str] = &[ + "StartAccepted", "StartRejected", "WorkflowStarted", "WorkflowContinuedAsNew", "ChildWorkflowScheduled", + "ChildCancellationRequested", "ChildCancellationResolved", "ChildRunStarted", "ChildRunCompleted", "ChildRunFailed", + "ChildRunCancelled", "ChildRunTerminated", "ServiceCallStarted", "ServiceCallCompleted", "ServiceCallFailed", + "ServiceCallCancelled", "ConditionWaitOpened", "ConditionWaitSatisfied", "ConditionWaitTimedOut", + "ConditionWaitCancelled", "SignalWaitOpened", "SignalWaitCancelled", "SignalReceived", "SignalApplied", + "SignalRejected", "UpdateAccepted", "UpdateRejected", "UpdateApplied", "UpdateCompleted", "RepairRequested", + "CancelRequested", "CooperativeCancellationRequested", "CooperativeCancellationDelivered", "CancellationScopeOpened", + "CancellationScopeRequested", "CancellationScopeDeliveryPrepared", "CancellationScopeDelivered", + "CancellationScopeRequestConflicted", "WorkflowCancelled", "TerminateRequested", "WorkflowTerminated", + "ArchiveRequested", "WorkflowArchived", "ActivityScheduled", "ActivityStarted", "ActivityHeartbeatRecorded", + "ActivityRetryScheduled", "ActivityCompleted", "ActivityFailed", "ActivityCancelled", + "ActivityCancellationAcknowledged", "ActivityTimedOut", "FailureHandled", "SideEffectRecorded", + "VersionMarkerRecorded", "TimerScheduled", "TimerFired", "TimerCancelled", "SelectionResolved", + "SelectionOperationCancelled", "SearchAttributesUpserted", "MemoUpserted", "WorkflowTimedOut", "WorkflowCompleted", + "WorkflowFailed", "ParentClosePolicyApplied", "ParentClosePolicyFailed", "ParentCloseCancellationRequested", + "MessageCursorAdvanced", "ScheduleCreated", "SchedulePaused", "ScheduleResumed", "ScheduleUpdated", + "ScheduleTriggered", "ScheduleDeleted", "ScheduleTriggerSkipped", +]; +const COMMAND_TYPES: &[&str] = &["start", "redrive", "signal", "update", "repair", "cancel", "request_cancellation", "terminate", "archive"]; +const COMMAND_STATUSES: &[&str] = &["accepted", "rejected"]; +const COMMAND_OUTCOMES: &[&str] = &[ + "started_new", "redriven", "returned_existing_active", "rejected_duplicate", "signal_received", "update_completed", + "update_failed", "repair_dispatched", "repair_not_needed", "cancelled", "cancellation_requested", "terminated", + "archived", "archive_not_needed", "rejected_not_started", "rejected_not_active", "rejected_not_current", + "rejected_run_not_closed", "rejected_unknown_signal", "rejected_unknown_update", "rejected_invalid_arguments", + "rejected_compatibility_blocked", "rejected_workflow_definition_unavailable", +]; +const SIGNAL_STATUSES: &[&str] = &["received", "applied", "rejected"]; +const UPDATE_STATUSES: &[&str] = &["accepted", "rejected", "completed", "failed"]; +const TASK_TYPES: &[&str] = &["workflow", "activity", "timer"]; +const TASK_STATUSES: &[&str] = &["ready", "leased", "cancelled", "completed", "failed"]; +const ACTIVITY_STATUSES: &[&str] = &["pending", "running", "cancelled", "completed", "failed"]; +const ATTEMPT_STATUSES: &[&str] = &["running", "completed", "failed", "cancelled", "expired"]; +const TIMER_STATUSES: &[&str] = &["pending", "cancelled", "fired"]; +const FAILURE_CATEGORIES: &[&str] = &[ + "application", "cancelled", "terminated", "timeout", "activity", "child_workflow", "task_failure", "internal", "structural_limit", +]; +/// `EmbeddedV2HistoryImport::HISTORY_EVENT_PAYLOAD_FIELDS`. +const HISTORY_EVENT_PAYLOAD_FIELDS: &[(&str, &[&str])] = &[ + ("ActivityCompleted", &["result"]), + ("ChildRunCompleted", &["output", "result"]), + ("MemoUpserted", &["entries", "merged"]), + ("ServiceCallStarted", &["request_payload", "response_payload"]), + ("ServiceCallCompleted", &["request_payload", "response_payload"]), + ("ServiceCallFailed", &["request_payload", "response_payload"]), + ("ServiceCallCancelled", &["request_payload", "response_payload"]), + ("SideEffectRecorded", &["result"]), + ("SignalReceived", &["arguments"]), + ("SignalApplied", &["arguments", "value"]), + ("UpdateAccepted", &["arguments"]), + ("UpdateRejected", &["arguments"]), + ("UpdateApplied", &["arguments"]), + ("UpdateCompleted", &["result"]), + ("WorkflowCompleted", &["output"]), +]; +const ACTIVITY_SNAPSHOT_EVENT_TYPES: &[&str] = &[ + "ActivityScheduled", "ActivityStarted", "ActivityHeartbeatRecorded", "ActivityRetryScheduled", "ActivityCompleted", + "ActivityFailed", "ActivityCancelled", "ActivityTimedOut", +]; +const FAILURE_PAYLOAD_EVENT_TYPES: &[&str] = &[ + "ActivityRetryScheduled", "ActivityFailed", "ChildRunFailed", "ServiceCallFailed", "ServiceCallCancelled", + "UpdateCompleted", "WorkflowFailed", +]; +const SERVICE_CALL_EVENT_TYPES: &[&str] = &["ServiceCallStarted", "ServiceCallCompleted", "ServiceCallFailed", "ServiceCallCancelled"]; +/// `PAYLOAD_ROW_FIELDS`, in the importer's order. +const PAYLOAD_ROW_FIELDS: &[(&str, &[&str])] = &[ + ("commands", &["payload"]), + ("signals", &["arguments"]), + ("updates", &["arguments", "result"]), + ("activities", &["arguments", "result"]), +]; +/// The report's row counts, in PHP's order. +pub(super) const ROWS: [&str; 15] = [ + "workflow_instances", "workflow_runs", "workflow_history_events", "workflow_commands", "workflow_signal_records", + "workflow_updates", "workflow_tasks", "activity_executions", "activity_attempts", "workflow_run_timers", + "workflow_failures", "workflow_links", "workflow_memos", "workflow_search_attributes", "workflow_run_summaries", +]; +const MEMO_VALUE_LIMIT: usize = 10_240; +const SEARCH_KEYWORD_LIMIT: usize = 255; +const SEARCH_STRING_LIMIT: usize = 2048; + +pub(crate) type Rows = [i64; 15]; + +fn rows_json(rows: &Rows) -> Value { + Value::Object(ROWS.iter().zip(rows).map(|(name, count)| ((*name).to_owned(), json!(count))).collect()) +} + +fn row(name: &str) -> usize { + ROWS.iter().position(|candidate| *candidate == name).expect("known row") +} + +// The importer's value coercions. + +/// `stringValue`: a non-empty string. +pub(super) fn text(value: Option<&Php>) -> Option<&str> { + value.and_then(Php::str).filter(|text| !text.is_empty()) +} + +/// `intValue`: an integer, or a numeric string cast to one. +pub(super) fn int(value: Option<&Php>) -> Option { + match value? { + Php::Int(number) => Some(*number), + Php::Str(text) => php_value::int_cast(text), + _ => None, + } +} + +/// `listValue`: the array elements of an array, in order. +pub(super) fn list(value: Option<&Php>) -> Vec<&Php> { + value.and_then(Php::array).map(|array| array.values().filter(|row| row.is_array()).collect()).unwrap_or_default() +} + +fn array(value: Option<&Php>) -> Option<&PhpArray> { + value.and_then(Php::array) +} + +/// `enumString`: a known backing value. +fn enumerated<'a>(allowed: &[&str], value: Option<&'a Php>) -> Option<&'a str> { + text(value).filter(|text| allowed.contains(text)) +} + +/// `ulidValue`: a non-empty string of at most 26 bytes. +fn ulid_value(value: Option<&Php>) -> Option<&str> { + text(value).filter(|text| text.len() <= 26) +} + +fn truthy(value: Option<&Php>) -> bool { + value.is_some_and(Php::truthy) +} + +fn max_sequence(rows: &[&Php], key: &str) -> i64 { + rows.iter().filter_map(|row| int(row.get(key))).fold(0, i64::max) +} + +/// The options PHP's controller reads from the body (or the query string, +/// except the signing key). +pub(super) struct Options { + pub(super) dry_run: bool, + pub(super) import_id: Option, + pub(super) require_signature: bool, + pub(super) signing_key: Option, +} + +/// `$request->boolean($key)`: `filter_var(FILTER_VALIDATE_BOOLEAN)`. +fn boolean(value: Option<&Php>) -> bool { + match value { + Some(Php::Bool(flag)) => *flag, + Some(Php::Int(number)) => *number == 1, + Some(Php::Float(number)) => *number == 1.0, + Some(Php::Str(text)) => { + let text = text.trim_matches([' ', '\t', '\n', '\r', '\x0b', '\0']).to_ascii_lowercase(); + matches!(text.as_str(), "1" | "true" | "on" | "yes") + } + _ => false, + } +} + +/// `stringInput`: a string, trimmed, with an empty one absent. +fn string_input(value: Option<&Php>) -> Option { + let text = value?.str()?.trim_matches([' ', '\t', '\n', '\r', '\x0b', '\0']); + (!text.is_empty()).then(|| text.to_owned()) +} + +/// The options the query string may carry. The signing key is not one. +const QUERY_OPTION_KEYS: [&str; 3] = ["dry_run", "import_id", "require_signature"]; + +/// What the controller decides before the importer runs. +pub(super) enum Request { + /// The 422 `invalid_import_bundle` answer. + Invalid, + /// The 422 `signing_key_in_query` answer: a signing key in the URL. + KeyInQuery, + Import(PhpArray, Options), +} + +/// `EmbeddedV2ImportController::store` up to the importer: the bundle is +/// `bundle` when that is an array, else the whole body; options come from +/// the body, then the query string (`$request->input`). +/// +/// Unlike PHP, native reads `signing_key` only from the body and refuses a +/// request that puts one in the query string (Server #765). Access logs, +/// tracing and client history keep URLs, and the key verifies the HMAC. +pub(super) fn request(body: &[u8], query: &[(String, String)]) -> Request { + // Any spelling PHP would read as the option (`signing_key[]` too). + if query.iter().any(|(key, _)| key.starts_with("signing_key")) { + return Request::KeyInQuery; + } + let body = php_value::request_body(body); + // Only the options are read, so only they are kept; a repeated + // parameter's last value wins. + let mut query_input = PhpArray::default(); + for (key, value) in query.iter().filter(|(key, _)| QUERY_OPTION_KEYS.contains(&key.as_str())) { + let mut value = Php::Str(value.clone()); + value.normalize(); + query_input.insert(Key::of(key), value); + } + let input = |key: &str| body.get(key).or_else(|| query_input.get(key)); + let options = Options { + dry_run: boolean(input("dry_run")), + import_id: string_input(input("import_id")), + require_signature: boolean(input("require_signature")), + signing_key: string_input(body.get("signing_key")), + }; + let bundle = match body.get("bundle") { + Some(Php::Array(bundle)) => bundle.clone(), + _ => body, + }; + if bundle.is_empty() { + return Request::Invalid; + } + Request::Import(bundle, options) +} + +/// `workflowSummary`: the bundle's workflow with the importer's defaults, +/// in the request's namespace. +pub(crate) struct Summary { + pub(super) workflow: PhpArray, + pub(super) instance_id: Option, + pub(super) run_id: Option, + pub(super) run_number: i64, + pub(super) workflow_type: String, + pub(super) workflow_class: String, + pub(super) namespace: String, + pub(super) status: String, +} + +impl Summary { + fn of(bundle: &PhpArray, namespace: &str) -> Self { + let workflow = array(bundle.get("workflow")).cloned().unwrap_or_default(); + let field = |key: &str| text(workflow.get(key)).map(str::to_owned); + let workflow_type = field("workflow_type").or_else(|| field("workflow_class")).unwrap_or_else(|| "imported-workflow".into()); + Self { + instance_id: field("instance_id"), + run_id: field("run_id"), + run_number: int(workflow.get("run_number")).unwrap_or(1), + workflow_class: field("workflow_class").unwrap_or_else(|| workflow_type.clone()), + workflow_type, + namespace: namespace.to_owned(), + status: field("status").unwrap_or_else(|| "running".into()), + workflow, + } + } + + fn get(&self, key: &str) -> Option<&Php> { + self.workflow.get(key) + } + + fn terminal(&self) -> bool { + TERMINAL_STATUSES.contains(&self.status.as_str()) + } +} + +struct Eligibility { + errors: Vec, + warnings: Vec, +} + +impl Eligibility { + fn json(&self) -> Value { + json!({"eligible": self.errors.is_empty(), "errors": self.errors, "warnings": self.warnings}) + } + + fn with_error(&self, rule: &str, message: &str) -> Value { + self.with_errors(vec![finding(rule, message)]) + } + + fn with_errors(&self, findings: Vec) -> Value { + let mut errors = self.errors.clone(); + errors.extend(findings); + json!({"eligible": false, "errors": errors, "warnings": self.warnings}) + } +} + +fn finding(rule: &str, message: &str) -> Value { + json!({"rule": rule, "message": message}) +} + +/// `eligibility`, in PHP's order. +fn eligibility(bundle: &PhpArray, integrity: &str, signature_verified: bool, summary: &Summary, options: &Options) -> Eligibility { + let root = Php::Array(bundle.clone()); + let mut errors = Vec::new(); + let mut warnings = Vec::new(); + match integrity { + "failed" => errors.push(finding("bundle.integrity_failed", "Bundle integrity verification failed.")), + "warning" => warnings.push(finding("bundle.integrity_warning", "Bundle integrity verification reported warnings.")), + _ => {} + } + if options.require_signature && !signature_verified { + errors.push(finding("bundle.signature_required", "A verified history-export signature is required for this import.")); + } + if summary.instance_id.is_none() { + errors.push(finding("workflow.instance_id_missing", "Bundle workflow.instance_id is required.")); + } + if summary.run_id.is_none() { + errors.push(finding("workflow.run_id_missing", "Bundle workflow.run_id is required.")); + } + if !RUN_STATUSES.contains(&summary.status.as_str()) { + errors.push(finding("workflow.status_unsupported", "Bundle workflow.status is not a supported v2 run status.")); + } else if summary.terminal() { + if !matches!(bundle.get("history_complete"), Some(Php::Bool(true))) { + errors.push(finding("workflow.terminal_history_incomplete", "Terminal run imports require history_complete=true.")); + } + } else if !truthy(summary.get("is_current_run")) { + errors.push(finding("workflow.non_terminal_not_current", "Non-terminal embedded v2 imports must be the current run.")); + } + if text(summary.get("source_runtime")) != Some(SOURCE_RUNTIME) { + errors.push(finding("workflow.source_runtime_unsupported", "Bundle workflow.source_runtime must be embedded.")); + } + // Only non-scalar path elements count, so exported string paths pass. + if !list(root.at(&["redaction", "paths"])).is_empty() { + errors.push(finding("bundle.redacted_payloads", "Redacted history bundles cannot be imported as server-managed state.")); + } + reject_unsupported_payload_codecs(&root, &mut errors); + reject_open_leases(&root, &mut errors); + Eligibility { errors, warnings } +} + +fn reject_payload_codec(codec: Option<&Php>, path: &str, errors: &mut Vec) { + let found = match codec { + None | Some(Php::Null) => return, + Some(Php::Str(codec)) if codec.is_empty() || codec == "avro" => return, + Some(Php::Str(codec)) => format!("\"{codec}\""), + Some(other) => other.debug_type().to_owned(), + }; + errors.push(finding("payload_codec.unsupported", &format!( + "Bundle payload codec at [{path}] must be \"avro\"; found {found}. JSON is only the HTTP document transport and cannot be imported as durable payload data."))); +} + +fn inspect_payload_envelope(value: Option<&Php>, path: &str, errors: &mut Vec) { + let Some(envelope) = value.and_then(Php::array) else { + return; + }; + if envelope.contains("blob") || envelope.contains("external_storage") { + reject_payload_codec(envelope.get("codec"), &format!("{path}.codec"), errors); + } + if let Some(external) = array(envelope.get("external_storage")) { + reject_payload_codec(external.get("codec"), &format!("{path}.external_storage.codec"), errors); + } +} + +fn inspect_payload_row(row: &Php, path: &str, fields: &[&str], errors: &mut Vec) { + reject_payload_codec(row.get("payload_codec"), &format!("{path}.payload_codec"), errors); + for field in fields { + inspect_payload_envelope(row.get(field), &format!("{path}.{field}"), errors); + } +} + +fn inspect_failure_details(value: Option<&Php>, path: &str, errors: &mut Vec) { + let Some(details) = value.filter(|value| value.is_array()) else { + return; + }; + reject_payload_codec(details.get("details_payload_codec"), &format!("{path}.details_payload_codec"), errors); + inspect_payload_envelope(details.get("details"), &format!("{path}.details"), errors); +} + +/// `rejectUnsupportedPayloadCodecs`, one finding per offending path in the +/// importer's walk order. +fn reject_unsupported_payload_codecs(bundle: &Php, errors: &mut Vec) { + let empty = Php::Array(PhpArray::default()); + let payloads = bundle.get("payloads").filter(|value| value.is_array()).unwrap_or(&empty); + let arguments = payloads.get("arguments").filter(|value| value.is_array()).unwrap_or(&empty); + let output = payloads.get("output").filter(|value| value.is_array()).unwrap_or(&empty); + reject_payload_codec(payloads.get("codec"), "payloads.codec", errors); + reject_payload_codec(output.get("codec"), "payloads.output.codec", errors); + inspect_payload_envelope(arguments.get("data"), "payloads.arguments.data", errors); + inspect_payload_envelope(output.get("data"), "payloads.output.data", errors); + for (section, fields) in PAYLOAD_ROW_FIELDS { + for (index, row) in list(bundle.get(section)).into_iter().enumerate() { + inspect_payload_row(row, &format!("{section}.{index}"), fields, errors); + } + } + for (index, event) in list(bundle.get("history_events")).into_iter().enumerate() { + let kind = text(event.get("type")); + let payload = event.get("payload").filter(|value| value.is_array()).unwrap_or(&empty); + let path = format!("history_events.{index}.payload"); + if let Some((_, fields)) = kind.and_then(|kind| HISTORY_EVENT_PAYLOAD_FIELDS.iter().find(|(name, _)| *name == kind)) { + inspect_payload_row(payload, &path, fields, errors); + } + let Some(kind) = kind else { + continue; + }; + if ACTIVITY_SNAPSHOT_EVENT_TYPES.contains(&kind) + && let Some(activity) = payload.get("activity").filter(|value| value.is_array()) + { + inspect_payload_row(activity, &format!("{path}.activity"), &["arguments", "result", "exception"], errors); + inspect_failure_details(activity.get("exception"), &format!("{path}.activity.exception"), errors); + } + if FAILURE_PAYLOAD_EVENT_TYPES.contains(&kind) { + inspect_failure_details(payload.get("exception"), &format!("{path}.exception"), errors); + } + if SERVICE_CALL_EVENT_TYPES.contains(&kind) { + for surface in ["service_call", "response_or_failure_surface"] { + let Some(value) = payload.get(surface).filter(|value| value.is_array()) else { + continue; + }; + inspect_payload_row(value, &format!("{path}.{surface}"), &["request_payload", "response_payload"], errors); + inspect_failure_details(value.get("exception"), &format!("{path}.{surface}.exception"), errors); + } + } + } + let manifest = bundle.get("payload_manifest").filter(|value| value.is_array()).unwrap_or(&empty); + for (index, entry) in list(manifest.get("entries")).into_iter().enumerate() { + reject_payload_codec(entry.get("codec"), &format!("payload_manifest.entries.{index}.codec"), errors); + } +} + +/// `rejectOpenLeases`: the first leased task, else the first running attempt. +fn reject_open_leases(bundle: &Php, errors: &mut Vec) { + if list(bundle.get("tasks")).iter().any(|task| task.get("status").and_then(Php::str) == Some("leased")) { + errors.push(finding("tasks.leased_task_present", "Leased workflow or activity tasks must be completed, failed, or released before import.")); + return; + } + let running = list(bundle.get("activities")).iter().any(|activity| { + list(activity.get("attempts")).iter().any(|attempt| attempt.get("status").and_then(Php::str) == Some("running")) + }); + if running { + errors.push(finding("activities.running_attempt_present", "Running activity attempts must be released before import.")); + } +} + +/// The ids of the rows the writer imports, selected as it selects them. +#[derive(Default)] +struct BundleIds<'a> { + /// Each command's id and type. + commands: HashMap<&'a str, &'a str>, + signals: HashSet<&'a str>, + updates: HashSet<&'a str>, + tasks: HashSet<&'a str>, + activities: HashSet<&'a str>, + attempts: HashSet<&'a str>, + timers: HashSet<&'a str>, + failures: HashSet<&'a str>, +} + +impl<'a> BundleIds<'a> { + fn of(bundle: &'a Php) -> Self { + let mut ids = Self::default(); + for command in list(bundle.get("commands")) { + if let (Some(kind), Some(_), Some(id)) = (enumerated(COMMAND_TYPES, command.get("type")), + enumerated(COMMAND_STATUSES, command.get("status")), text(command.get("id"))) + { + ids.commands.insert(id, kind); + } + } + let collect = |section: &str, written: &dyn Fn(&Php) -> bool| -> HashSet<&'a str> { + list(bundle.get(section)).into_iter().filter(|row| written(row)).filter_map(|row| text(row.get("id"))).collect() + }; + ids.signals = collect("signals", &|row| enumerated(SIGNAL_STATUSES, row.get("status")).is_some()); + ids.updates = collect("updates", &|row| enumerated(UPDATE_STATUSES, row.get("status")).is_some()); + ids.tasks = collect("tasks", &|row| { + enumerated(TASK_TYPES, row.get("type")).is_some() && enumerated(TASK_STATUSES, row.get("status")).is_some() + }); + ids.activities = collect("activities", &|row| enumerated(ACTIVITY_STATUSES, row.get("status")).is_some()); + ids.timers = collect("timers", &|row| enumerated(TIMER_STATUSES, row.get("status")).is_some()); + ids.failures = collect("failures", &|_| true); + // The writer imports the attempts of every activity with an id. + for activity in list(bundle.get("activities")).into_iter().filter(|activity| text(activity.get("id")).is_some()) { + for attempt in list(activity.get("attempts")) { + if let (Some(_), Some(id)) = (enumerated(ATTEMPT_STATUSES, attempt.get("status")), text(attempt.get("id"))) { + ids.attempts.insert(id); + } + } + } + ids + } +} + +/// Each imported command, signal and update that requests a run other +/// than the imported one: its path and that run. +fn requested_runs<'a>(bundle: &'a Php, run_id: &str) -> Vec<(String, &'a str)> { + let mut requested = Vec::new(); + for (section, statuses) in [("commands", COMMAND_STATUSES), ("signals", SIGNAL_STATUSES), ("updates", UPDATE_STATUSES)] { + for (index, row) in list(bundle.get(section)).into_iter().enumerate() { + let written = enumerated(statuses, row.get("status")).is_some() + && (section != "commands" || enumerated(COMMAND_TYPES, row.get("type")).is_some()); + if let Some(run) = text(row.get("requested_run_id")).filter(|run| written && *run != run_id) { + requested.push((format!("{section}.{index}.requested_run_id"), run)); + } + } + } + requested +} + +/// The references a task payload carries, each to rows of one kind. +const TASK_PAYLOAD_REFERENCES: &[(&str, &str)] = &[ + ("activity_execution_id", "an activity"), + ("activity_attempt_id", "an activity attempt"), + ("timer_id", "a timer"), + ("workflow_signal_id", "a signal"), + ("workflow_update_id", "an update"), + ("workflow_command_id", "a command"), +]; + +/// Native refuses a bundle whose rows point outside the bundle (Server +/// #765). Published PHP copies every reference unchecked, and the runtime +/// joins or updates the rows they name: a signal or update applies to its +/// command by id, a lost activity lease settles the activity its task names, +/// a lineage row puts a related run into history exports. So each copied +/// reference must name a row this import writes, of the expected kind. A +/// requested run may also be another run of the same workflow, and a failed +/// update a failure no run holds; the write checks those and lineage +/// against the store. History event payloads are history, not references: +/// what the runtime resolves from them stays within the run. +fn reject_foreign_references(bundle: &Php, summary: &Summary, errors: &mut Vec) { + let mut check = References { ids: BundleIds::of(bundle), run_id: summary.run_id.as_deref(), errors }; + if let Some(id) = text(summary.get("archive_command_id")) + && !check.command(id, None) + { + check.unresolved("workflow.archive_command_id".into(), "a command"); + } + check.history(bundle); + check.messages(bundle); + check.activities(bundle); + check.tasks(bundle); + check.failures(bundle); + check.links(bundle); +} + +/// One bundle's reference checks and the findings they add. +struct References<'a, 'e> { + ids: BundleIds<'a>, + run_id: Option<&'a str>, + errors: &'e mut Vec, +} + +impl References<'_, '_> { + fn unresolved(&mut self, path: String, kind: &str) { + self.errors.push(finding("references.unresolved", &format!("Bundle {path} must name {kind} in this bundle."))); + } + + fn command(&self, id: &str, kind: Option<&str>) -> bool { + self.ids.commands.get(id).is_some_and(|found| kind.is_none_or(|kind| *found == kind)) + } + + /// Whether `id` names a row of the kind a task payload key or a resume + /// source kind refers to. + fn names(&self, key: &str, id: &str) -> bool { + match key { + "activity_execution_id" | "activity_execution" => self.ids.activities.contains(id), + "activity_attempt_id" => self.ids.attempts.contains(id), + "timer_id" | "timer" => self.ids.timers.contains(id), + "workflow_signal_id" | "workflow_signal" => self.ids.signals.contains(id), + "workflow_update_id" | "workflow_update" => self.ids.updates.contains(id), + _ => self.command(id, None), + } + } + + fn history(&mut self, bundle: &Php) { + for (index, event) in list(bundle.get("history_events")).into_iter().enumerate() { + if enumerated(HISTORY_EVENT_TYPES, event.get("type")).is_none() { + continue; + } + if text(event.get("workflow_task_id")).is_some_and(|id| !self.ids.tasks.contains(id)) { + self.unresolved(format!("history_events.{index}.workflow_task_id"), "a task"); + } + if text(event.get("workflow_command_id")).is_some_and(|id| !self.command(id, None)) { + self.unresolved(format!("history_events.{index}.workflow_command_id"), "a command"); + } + } + } + + /// Commands, signals and updates resolve to this run, and a signal's or + /// update's command is one of this bundle's of its kind. + fn messages(&mut self, bundle: &Php) { + for (section, statuses, kind) in + [("commands", COMMAND_STATUSES, None), ("signals", SIGNAL_STATUSES, Some("signal")), ("updates", UPDATE_STATUSES, Some("update"))] + { + for (index, row) in list(bundle.get(section)).into_iter().enumerate() { + let written = enumerated(statuses, row.get("status")).is_some() + && (kind.is_some() || enumerated(COMMAND_TYPES, row.get("type")).is_some()); + if !written { + continue; + } + if text(row.get("resolved_run_id")).is_some_and(|id| Some(id) != self.run_id) { + self.errors.push(finding("references.foreign_run", + &format!("Bundle {section}.{index}.resolved_run_id must name the imported run."))); + } + if let Some(kind) = kind + && text(row.get("command_id")).is_some_and(|id| !self.command(id, Some(kind))) + { + let expected = if kind == "update" { "an update command" } else { "a signal command" }; + self.unresolved(format!("{section}.{index}.command_id"), expected); + } + } + } + } + + fn activities(&mut self, bundle: &Php) { + for (index, activity) in list(bundle.get("activities")).into_iter().enumerate() { + if text(activity.get("id")).is_none() { + continue; + } + let attempts: Vec<(usize, &Php)> = list(activity.get("attempts")).into_iter().enumerate() + .filter(|(_, attempt)| enumerated(ATTEMPT_STATUSES, attempt.get("status")).is_some()) + .collect(); + // The writer imports these attempts even when it skips the activity. + if enumerated(ACTIVITY_STATUSES, activity.get("status")).is_none() { + if !attempts.is_empty() { + self.unresolved(format!("activities.{index}.attempts"), "attempts of an imported activity"); + } + continue; + } + let own: HashSet<&str> = attempts.iter().filter_map(|(_, attempt)| text(attempt.get("id"))).collect(); + if text(activity.get("current_attempt_id")).is_some_and(|id| !own.contains(id)) { + self.unresolved(format!("activities.{index}.current_attempt_id"), "one of the activity's own attempts"); + } + for (position, attempt) in attempts { + for key in ["workflow_task_id", "task_id"] { + if text(attempt.get(key)).is_some_and(|id| !self.ids.tasks.contains(id)) { + self.unresolved(format!("activities.{index}.attempts.{position}.{key}"), "a task"); + } + } + } + } + } + + fn tasks(&mut self, bundle: &Php) { + for (index, task) in list(bundle.get("tasks")).into_iter().enumerate() { + if enumerated(TASK_TYPES, task.get("type")).is_none() || enumerated(TASK_STATUSES, task.get("status")).is_none() { + continue; + } + let Some(payload) = task.get("payload").filter(|payload| payload.is_array()) else { + continue; + }; + for (key, kind) in TASK_PAYLOAD_REFERENCES { + if text(payload.get(key)).is_some_and(|id| !self.names(key, id)) { + self.unresolved(format!("tasks.{index}.payload.{key}"), kind); + } + } + // A wait's resume source, for the kinds the bundle itself carries. + if let (Some(kind @ ("activity_execution" | "timer" | "workflow_signal" | "workflow_update")), Some(id)) = + (text(payload.get("resume_source_kind")), text(payload.get("resume_source_id"))) + && !self.names(kind, id) + { + self.unresolved(format!("tasks.{index}.payload.resume_source_id"), &format!("the {kind} it resumes")); + } + } + } + + fn failures(&mut self, bundle: &Php) { + for (index, failure) in list(bundle.get("failures")).into_iter().enumerate() { + if text(failure.get("source_kind")) == Some("activity_execution") + && text(failure.get("source_id")).is_some_and(|id| !self.ids.activities.contains(id)) + { + self.unresolved(format!("failures.{index}.source_id"), "an activity"); + } + } + } + + /// A link never names the imported run as its own related run; the + /// write checks the related run against the store. + fn links(&mut self, bundle: &Php) { + let Some(run_id) = self.run_id else { + return; + }; + let empty = Php::Array(PhpArray::default()); + let links = bundle.get("links").filter(|value| value.is_array()).unwrap_or(&empty); + for (side, other) in [("parents", "parent"), ("children", "child")] { + for (index, link) in list(links.get(side)).into_iter().enumerate() { + if text(link.get(&format!("{other}_workflow_run_id"))) == Some(run_id) { + self.errors.push(finding("references.self_link", + &format!("Bundle links.{side}.{index} names the imported run as its own {other}."))); + } + } + } + } +} + +/// `estimatedRows`: memos and search attributes are never estimated. +fn estimated_rows(bundle: &Php) -> Rows { + let count = |section: &str| list(bundle.get(section)).len() as i64; + let mut rows = [0; 15]; + rows[row("workflow_instances")] = 1; + rows[row("workflow_runs")] = 1; + rows[row("workflow_history_events")] = count("history_events"); + rows[row("workflow_commands")] = count("commands"); + rows[row("workflow_signal_records")] = count("signals"); + rows[row("workflow_updates")] = count("updates"); + rows[row("workflow_tasks")] = count("tasks"); + rows[row("activity_executions")] = count("activities"); + rows[row("activity_attempts")] = list(bundle.get("activities")).iter().map(|activity| list(activity.get("attempts")).len() as i64).sum(); + rows[row("workflow_run_timers")] = count("timers"); + rows[row("workflow_failures")] = count("failures"); + let links = bundle.get("links").filter(|value| value.is_array()); + rows[row("workflow_links")] = links.map_or(0, |links| (list(links.get("parents")).len() + list(links.get("children")).len()) as i64); + rows[row("workflow_run_summaries")] = 1; + rows +} + +/// Everything decided before the store is read. +pub(super) struct Prepared { + bundle: PhpArray, + options: Options, + summary: Summary, + eligibility: Eligibility, + base: Map, + memo_entries: Option>, +} + +/// The configured `workflows.v2.history_export.signing_key`, trimmed. +fn configured_signing_key() -> Option { + ["DW_V2_HISTORY_EXPORT_SIGNING_KEY", "WORKFLOW_V2_HISTORY_EXPORT_SIGNING_KEY"] + .iter() + .find_map(|name| std::env::var(name).ok()) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) +} + +/// Summary, verification, eligibility and the report's base fields. +pub(super) fn prepare(bundle: PhpArray, options: Options, namespace: &str) -> Prepared { + let summary = Summary::of(&bundle, namespace); + let configured = configured_signing_key(); + let verification = bundle_verifier::verify(&bundle, options.signing_key.as_deref(), configured.as_deref()); + let verified = verification.report["integrity"]["signature_verified"] == json!(true); + let eligibility = eligibility(&bundle, verification.status, verified, &summary, &options); + let workflow = json!({ + "instance_id": summary.instance_id, + "run_id": summary.run_id, + "run_number": summary.run_number, + "workflow_type": summary.workflow_type, + "namespace": summary.namespace, + "status": summary.status, + "is_current_run": truthy(summary.get("is_current_run")), + "history_complete": truthy(bundle.get("history_complete")), + "dedupe_key": text(bundle.get("dedupe_key")), + }); + let mut base = Map::new(); + base.insert("schema".into(), json!(REPORT_SCHEMA)); + base.insert("schema_version".into(), json!(1)); + base.insert("contract_version".into(), json!(CONTRACT_VERSION)); + base.insert("source".into(), json!(IMPORT_SOURCE)); + base.insert("dry_run".into(), json!(options.dry_run)); + base.insert("workflow".into(), workflow); + base.insert("eligibility".into(), eligibility.json()); + base.insert("integrity".into(), verification.report); + base.insert("rollback".into(), json!({"mode":"database_transaction","partial_import_behavior":"no_rows_committed_on_failure"})); + Prepared { bundle, options, summary, eligibility, base, memo_entries: verification.memo_entries } +} + +fn report(base: &Map, status: &str, eligibility: Option, rows: &Rows, imported: Option<(&str, &str)>) -> Value { + let mut body = base.clone(); + if let Some(eligibility) = eligibility { + body.insert("eligibility".into(), eligibility); + } + body.insert("status".into(), json!(status)); + if let Some((import_id, imported_at)) = imported { + body.insert("import_id".into(), json!(import_id)); + body.insert("imported_at".into(), json!(imported_at)); + } + body.insert("rows".into(), rows_json(rows)); + Value::Object(body) +} + +/// A run with the bundle's run id, wherever it lives. +pub(crate) struct ExistingRun { + pub(super) import_source: Option, + pub(super) dedupe_key: Option, + pub(super) namespace: Option, +} + +/// One prepared memo row: the stored projection and the canonical envelope. +pub(crate) struct MemoRow { + pub(super) key: String, + pub(super) value: Value, + pub(super) portable: Value, +} + +/// One typed search attribute row. +pub(crate) struct AttributeRow { + pub(super) key: String, + pub(super) value: super::search_attribute_upserts::Typed, +} + +/// The write plan the store executes in one transaction. +pub(crate) struct Plan { + pub(super) bundle: Php, + pub(super) summary: Summary, + pub(super) import_id: String, + pub(super) imported_at: DateTime, + /// The memo rows, or the importer's exception message. + pub(super) memos: std::result::Result, String>, + /// The search attribute rows, or the importer's exception message. + pub(super) attributes: std::result::Result, String>, +} + +/// `writeMemos`' rows from the decoded entries: every non-empty key, its +/// value re-encoded canonically and projected as the Server stores it. +fn memo_rows(summary: &Summary, decoded: Option>) -> std::result::Result, String> { + let entries = match decoded { + Some(entries) => entries, + None => { + let Some(payload) = array(summary.get("memo_payload")) else { + return Err("workflow.memo_payload must contain the lossless portable memo payload envelope.".into()); + }; + bundle_verifier::decode_entries(payload)? + } + }; + let codec = crate::codec::ValueCodec::new().map_err(|error| error.to_string())?; + let mut rows = Vec::new(); + for (key, value) in entries { + if key.is_empty() { + continue; + } + let blob = codec.encode_ordered(&value.protocol()).map_err(|error| error.to_string())?; + let size = crate::codec::php_base64(&blob).map_err(|error| error.to_string())?.len(); + if size > MEMO_VALUE_LIMIT { + return Err(format!("Memo value for key \"{key}\" exceeds maximum size of {MEMO_VALUE_LIMIT} bytes (actual: {size} bytes)")); + } + rows.push(MemoRow { value: bundle_verifier::stored_projection(&value), portable: json!({"codec":"avro","blob":blob}), key }); + } + Ok(rows) +} + +/// `writeSearchAttributes`' rows: `setTypedValueWithInference` for every +/// non-empty string key. +fn attribute_rows(summary: &Summary) -> std::result::Result, String> { + use super::search_attribute_upserts::Typed; + let Some(attributes) = array(summary.get("search_attributes")) else { + return Ok(Vec::new()); + }; + let length = |text: &str| text.chars().count(); + let scalar_text = |value: &Php| -> std::result::Result { + Ok(match value { + Php::Null => String::new(), + Php::Str(text) => text.clone(), + Php::Bool(true) => "1".into(), + Php::Bool(false) => String::new(), + Php::Int(number) => number.to_string(), + Php::Float(number) => php_value::float(*number, false), + Php::Array(_) => return Err("Cannot coerce value to string: array".into()), + }) + }; + let mut rows = Vec::new(); + for (key, value) in &attributes.0 { + let Key::Str(key) = key else { + continue; + }; + if key.is_empty() { + continue; + } + let typed = match value { + Php::Bool(flag) => Typed::Bool(*flag), + Php::Int(number) => Typed::Int(*number), + Php::Float(number) => Typed::Float(*number), + Php::Str(text) if length(text) > SEARCH_KEYWORD_LIMIT || text.chars().any(char::is_whitespace) => { + if length(text) > SEARCH_STRING_LIMIT { + return Err(format!("Search attribute string value exceeds maximum length ({} > {SEARCH_STRING_LIMIT})", length(text))); + } + Typed::String(text.clone()) + } + Php::Array(items) => { + if !items.is_list() { + return Err("Search attribute keyword_list value must be a list of strings.".into()); + } + let mut list = Vec::new(); + for item in items.values() { + let Php::Str(item) = item else { + return Err("Search attribute keyword_list entries must be strings.".into()); + }; + if length(item) > SEARCH_KEYWORD_LIMIT { + return Err(format!("Search attribute keyword_list entry exceeds maximum length ({} > {SEARCH_KEYWORD_LIMIT})", length(item))); + } + list.push(item.clone()); + } + Typed::KeywordList(list) + } + other => { + let keyword = scalar_text(other)?; + if length(&keyword) > SEARCH_KEYWORD_LIMIT { + return Err(format!("Search attribute keyword value exceeds maximum length ({} > {SEARCH_KEYWORD_LIMIT})", length(&keyword))); + } + Typed::Keyword(keyword) + } + }; + rows.push(AttributeRow { key: key.clone(), value: typed }); + } + Ok(rows) +} + +/// A parsed bundle time: its wall-clock time in its own offset, which +/// Eloquent stores for ordinary date columns, and the UTC instant, which the +/// history recorded-at and archive casts store. +#[derive(Clone, Copy, Debug)] +pub(super) struct Stamp { + pub(super) wall: DateTime, + pub(super) utc: DateTime, +} + +/// `Carbon::parse` for the times an export carries: RFC 3339 with an offset, +/// or a date and time without one (read in the UTC application timezone). +pub(super) fn parse_time(text: &str) -> std::result::Result { + if let Ok(time) = DateTime::parse_from_rfc3339(text).or_else(|_| DateTime::parse_from_str(text, "%Y-%m-%dT%H:%M:%S%.f%z")) { + return Ok(Stamp { wall: time.naive_local().and_utc(), utc: time.with_timezone(&Utc) }); + } + for format in ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f", "%Y-%m-%dT%H:%M", "%Y-%m-%d %H:%M"] { + if let Ok(time) = NaiveDateTime::parse_from_str(text, format) { + let time = time.and_utc(); + return Ok(Stamp { wall: time, utc: time }); + } + } + if let Ok(date) = chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d") { + let time = date.and_hms_opt(0, 0, 0).expect("midnight").and_utc(); + return Ok(Stamp { wall: time, utc: time }); + } + let first = text.chars().next().unwrap_or(' '); + Err(format!("Could not parse '{text}': Failed to parse time string ({text}) at position 0 ({first}): The timezone could not be found in the database")) +} + +/// `timestamp`: a non-empty string parsed, or absent. +pub(super) fn time(value: Option<&Php>) -> std::result::Result, String> { + text(value).map(parse_time).transpose() +} + +/// `payloadRowValue`: a stored payload string, an envelope's blob, or an +/// external-storage envelope encoded as a stored reference. +pub(super) fn payload_row_value(value: Option<&Php>) -> std::result::Result, String> { + if let Some(text) = text(value) { + return Ok(Some(text.to_owned())); + } + let Some(envelope) = value.and_then(Php::array) else { + return Ok(None); + }; + if let Some(blob) = text(envelope.get("blob")) { + return Ok(Some(blob.to_owned())); + } + if envelope.get("external_storage").is_some_and(Php::is_array) { + return encode_stored_envelope(envelope).map(Some); + } + Ok(None) +} + +/// `ExternalPayloads::encodeStoredEnvelope`. +fn encode_stored_envelope(envelope: &PhpArray) -> std::result::Result { + use base64::{Engine, engine::general_purpose::STANDARD}; + let codec = text(envelope.get("codec")).ok_or("External payload envelope codec must be a non-empty string.")?; + let reference = array(envelope.get("external_storage")).ok_or("External payload envelope must contain an external_storage object.")?; + if reference.get("schema").and_then(Php::str) != Some("durable-workflow.v2.external-payload-reference.v1") { + return Err("Unsupported external payload reference schema.".into()); + } + if text(reference.get("uri")).is_none() { + return Err("External payload reference URI must be a non-empty string.".into()); + } + let sha = reference.get("sha256").and_then(Php::str).ok_or("External payload reference sha256 must be a hex digest.")?; + if sha.len() != 64 || !sha.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err("External payload reference sha256 must be a hex digest.".into()); + } + if !matches!(reference.get("size_bytes"), Some(Php::Int(size)) if *size >= 0) { + return Err("External payload reference size_bytes must be a non-negative integer.".into()); + } + let reference_codec = text(reference.get("codec")).ok_or("External payload reference codec must be a non-empty string.")?; + let canonical = |codec: &str| -> std::result::Result<(), String> { + if codec == "avro" { + Ok(()) + } else { + Err(format!("unsupported_payload_codec: payload codec \"{codec}\" is not supported by Durable Workflow 2.0; use codec=\"avro\" with the fixed Avro Value schema and single-object framing. JSON remains the HTTP document transport, not a workflow payload codec.")) + } + }; + canonical(reference_codec)?; + canonical(codec)?; + let json = Php::Array(envelope.clone()).canonical().encode(Flags { unescaped_slashes: true, ..Flags::default() }); + Ok(format!("dw-external-payload:v1:{}", STANDARD.encode(json))) +} + +impl Runtime { + /// `EmbeddedV2ImportController::store` once the gates have passed. + pub(crate) async fn import_embedded_v2(&self, body: &[u8], query: &[(String, String)]) -> Result<(StatusCode, Value)> { + let operation = || super::control_plane::Operation::ImportEmbeddedV2; + let (bundle, options) = match request(body, query) { + Request::Invalid => { + return Ok((StatusCode::UNPROCESSABLE_ENTITY, operation().response(json!({ + "message": "Import request body must be a history-export bundle or {\"bundle\": ...}.", + "reason": "invalid_import_bundle"})))); + } + // The answer never repeats the key. + Request::KeyInQuery => { + return Ok((StatusCode::UNPROCESSABLE_ENTITY, operation().response(json!({ + "message": "signing_key must be sent in the request body, not in the URL.", + "reason": "signing_key_in_query"})))); + } + Request::Import(bundle, options) => (bundle, options), + }; + let namespace = self.namespace_name(); + // Canonical JSON, hashing and Avro decoding stay off the async workers. + let prepared = tokio::task::spawn_blocking(move || prepare(bundle, options, &namespace)) + .await + .map_err(|_| refuse(StatusCode::SERVICE_UNAVAILABLE, "history_import_unavailable"))?; + let empty = [0; 15]; + let answer = |status: &str, eligibility: Option, rows: &Rows, imported: Option<(&str, &str)>| { + let code = match status { + "imported" => StatusCode::CREATED, + "already_imported" | "dry_run" => StatusCode::OK, + _ => StatusCode::UNPROCESSABLE_ENTITY, + }; + (code, operation().response(report(&prepared.base, status, eligibility, rows, imported))) + }; + if !prepared.eligibility.errors.is_empty() { + return Ok(answer("rejected", None, &empty, None)); + } + let run_id = prepared.summary.run_id.clone().unwrap_or_default(); + let dedupe_key = text(prepared.bundle.get("dedupe_key")).map(str::to_owned); + if let Some(existing) = self.import_existing_run(&run_id).await? { + let same_bundle = existing.import_source.as_deref() == Some(IMPORT_SOURCE) + && existing.dedupe_key.is_some() + && existing.dedupe_key == dedupe_key; + // A run counts as already imported only in this namespace. + if same_bundle && existing.namespace.as_deref() == Some(prepared.summary.namespace.as_str()) { + return Ok(answer("already_imported", None, &empty, None)); + } + return Ok(answer("rejected", Some(prepared.eligibility.with_error("target.run_id_conflict", + "A workflow run with this run_id already exists and was not imported from the same bundle.")), &empty, None)); + } + let root = Php::Array(prepared.bundle.clone()); + // Native's reference fence (Server #765), after PHP's eligibility and + // run checks so their answers stay PHP's, and before a dry run or a + // write. + let mut references = Vec::new(); + reject_foreign_references(&root, &prepared.summary, &mut references); + if !references.is_empty() { + return Ok(answer("rejected", Some(prepared.eligibility.with_errors(references)), &empty, None)); + } + if prepared.options.dry_run { + return Ok(answer("dry_run", None, &estimated_rows(&root), None)); + } + let import_id = prepared.options.import_id.clone().unwrap_or_else(id); + let imported_at = now(); + let memos = memo_rows(&prepared.summary, prepared.memo_entries.clone()); + let attributes = attribute_rows(&prepared.summary); + let Prepared { summary, eligibility, base, .. } = prepared; + let plan = Plan { bundle: root, summary, import_id: import_id.clone(), imported_at, memos, attributes }; + let written = self.import_write(&plan).await; + let answer = |status: &str, eligibility: Option, rows: &Rows, imported: Option<(&str, &str)>| { + let code = if status == "imported" { StatusCode::CREATED } else { StatusCode::UNPROCESSABLE_ENTITY }; + (code, operation().response(report(&base, status, eligibility, rows, imported))) + }; + Ok(match written { + Ok(rows) => { + let at = super::task_queues::json_time(Some(imported_at)); + answer("imported", None, &rows, Some((&import_id, at.as_str().unwrap_or_default()))) + } + Err(message) => answer("rejected", Some(eligibility.with_error("target.write_failed", &message)), &empty, None), + }) + } +} + +/// The JSON column value PHP's array cast stores for `arrayValue(x) ?? []`. +fn document_or_empty(value: Option<&Php>) -> Value { + match value { + Some(Php::Array(array)) => Php::Array(array.clone()).to_json(), + _ => json!([]), + } +} + +/// The current time for a row PHP stamps with `now()`. +fn stamp_now() -> Stamp { + let at = now(); + Stamp { wall: at, utc: at } +} + +fn wall(stamp: Option) -> Option> { + stamp.map(|stamp| stamp.wall) +} + +/// An enum-cast column: PHP's `BackedEnum::from` refuses an unknown value. +fn cast_enum<'a>(allowed: &[&str], name: &str, value: Option<&'a str>) -> std::result::Result, String> { + match value { + Some(value) if !allowed.contains(&value) => { + Err(format!("\"{value}\" is not a valid backing value for enum Workflow\\V2\\Enums\\{name}")) + } + other => Ok(other), + } +} + +store_methods! { + /// The run with this id in any namespace: PHP's lookup is global, and a + /// run id is unique across the store. + pub(crate) async fn import_existing_run(&self, run_id: &str) -> Result> { + let Some(row) = Self::query("SELECT import_source,import_dedupe_key,namespace FROM workflow_runs WHERE id=$1") + .bind(run_id) + .fetch_optional(&self.pool) + .await? + else { + return Ok(None); + }; + Ok(Some(ExistingRun { + import_source: DB::optional_string(&row, "import_source")?, + dedupe_key: DB::optional_string(&row, "import_dedupe_key")?, + namespace: DB::optional_string(&row, "namespace")?, + })) + } + + pub(crate) fn namespace_name(&self) -> String { + self.namespace.as_ref().to_owned() + } + + /// `writeBundle` in one transaction: the written row counts, or the + /// message of whatever failed (nothing stays). + pub(crate) async fn import_write(&self, plan: &Plan) -> std::result::Result { + let mut tx = self.begin().await.map_err(|error| error.to_string())?; + let rows = Self::import_rows(&mut tx, plan, self.namespace.as_ref()).await?; + let run_id = plan.summary.run_id.as_deref().unwrap_or_default(); + tx.summary_changed(run_id); + tx.commit().await.map_err(|error| error.to_string())?; + self.wake.notify_waiters(); + Ok(rows) + } + + /// The references `reject_foreign_references` leaves to the store. A + /// requested run other than the imported one must be another run of the + /// same workflow in this namespace, and a failure a failed update names + /// outside the bundle must belong to no run (Server #765). + async fn check_store_references( + tx: &mut super::transaction::Transaction<'_, DB>, + bundle: &Php, + namespace: &str, + run_id: &str, + instance_id: &str, + ) -> std::result::Result<(), String> { + let field = |row: &DB::Row, name: &str| DB::optional_string(row, name).map_err(|error| error.to_string()); + for (path, requested) in requested_runs(bundle, run_id) { + let found = Self::query("SELECT namespace,workflow_instance_id FROM workflow_runs WHERE id=$1") + .bind(requested).fetch_optional(&mut **tx).await.map_err(|error| error.to_string())?; + let same_workflow = match &found { + Some(found) => field(found, "namespace")?.as_deref() == Some(namespace) + && field(found, "workflow_instance_id")?.as_deref() == Some(instance_id), + None => false, + }; + if !same_workflow { + return Err(format!("Imported {path} is not a run of this workflow in this namespace.")); + } + } + Self::check_update_failures(tx, bundle).await + } + + /// A failure a failed update names outside the bundle belongs to no run. + async fn check_update_failures( + tx: &mut super::transaction::Transaction<'_, DB>, + bundle: &Php, + ) -> std::result::Result<(), String> { + let db = |error: sqlx::Error| error.to_string(); + let failures = BundleIds::of(bundle).failures; + for (index, update) in list(bundle.get("updates")).into_iter().enumerate() { + if enumerated(UPDATE_STATUSES, update.get("status")).is_none() { + continue; + } + let Some(failure) = text(update.get("failure_id")).filter(|failure| !failures.contains(failure)) else { + continue; + }; + if Self::query("SELECT id FROM workflow_failures WHERE id=$1").bind(failure).fetch_optional(&mut **tx).await.map_err(db)?.is_some() { + return Err(format!("Imported updates.{index}.failure_id names a failure of another run.")); + } + } + Ok(()) + } + + /// Whether to write a lineage link to this related run now. Native + /// links only runs of one namespace (Server #765): a related run or + /// instance in another namespace fails the import. A related run that + /// does not exist yet is left to its own import, whose bundle carries + /// the same link, so no link ever waits for a run that a later import + /// could create in another namespace. + async fn import_link_endpoint( + tx: &mut super::transaction::Transaction<'_, DB>, + namespace: &str, + link_id: &str, + other_instance: &str, + other_run: &str, + ) -> std::result::Result { + let db = |error: sqlx::Error| error.to_string(); + let field = |row: &DB::Row, name: &str| DB::optional_string(row, name).map_err(|error| error.to_string()); + if let Some(related) = Self::query("SELECT namespace,workflow_instance_id FROM workflow_runs WHERE id=$1") + .bind(other_run).fetch_optional(&mut **tx).await.map_err(db)? + { + if field(&related, "namespace")?.as_deref() != Some(namespace) { + return Err(format!("Imported workflow link [{link_id}] names a run of another namespace.")); + } + if field(&related, "workflow_instance_id")?.as_deref() != Some(other_instance) { + return Err(format!("Imported workflow link [{link_id}] names a run outside the instance it names.")); + } + return Ok(true); + } + if let Some(owner) = Self::query("SELECT namespace FROM workflow_instances WHERE id=$1") + .bind(other_instance).fetch_optional(&mut **tx).await.map_err(db)? + && field(&owner, "namespace")?.as_deref() != Some(namespace) + { + return Err(format!("Imported workflow link [{link_id}] names an instance of another namespace.")); + } + Ok(false) + } + + async fn import_rows(tx: &mut super::transaction::Transaction<'_, DB>, plan: &Plan, namespace: &str) -> std::result::Result { + let db = |error: sqlx::Error| error.to_string(); + let mut rows: Rows = [0; 15]; + let bundle = &plan.bundle; + let summary = &plan.summary; + let imported = Stamp { wall: plan.imported_at, utc: plan.imported_at }; + let run_id = summary.run_id.as_deref().unwrap_or_default(); + let instance_id = summary.instance_id.as_deref().unwrap_or_default(); + let run_number = summary.run_number; + let events = list(bundle.get("history_events")); + let commands = list(bundle.get("commands")); + let history_sequence = int(summary.get("last_history_sequence")).unwrap_or(0).max(max_sequence(&events, "sequence")); + let last_command_sequence = max_sequence(&commands, "sequence"); + let message_cursor = max_sequence(&commands, "message_sequence"); + let empty = Php::Array(PhpArray::default()); + let payloads = bundle.get("payloads").filter(|value| value.is_array()); + let payload_codec = text(payloads.and_then(|payloads| payloads.get("codec"))).unwrap_or("avro").to_owned(); + let output = payloads.and_then(|payloads| payloads.get("output")).filter(|value| value.is_array()); + let output_codec = if matches!(output.and_then(|output| output.get("available")), Some(Php::Bool(true))) { + text(output.and_then(|output| output.get("codec"))) + .or_else(|| output.and_then(|output| output.get("data")).and_then(|data| text(data.get("codec")))) + .or_else(|| { + events.iter().rev() + .find(|event| event.get("type").and_then(Php::str) == Some("WorkflowCompleted") && event.get("payload").is_some_and(Php::is_array)) + .and_then(|event| text(event.at(&["payload", "payload_codec"]))) + }) + .map(str::to_owned) + } else { + None + }; + // `payloadData`: an available payload's stored value. + let payload_data = |key: &str| -> std::result::Result, String> { + let payload = payloads.and_then(|payloads| payloads.get(key)).filter(|value| value.is_array()); + match payload { + Some(payload) if matches!(payload.get("available"), Some(Php::Bool(true))) => payload_row_value(payload.get("data")), + _ => Ok(None), + } + }; + let field = |key: &str| text(summary.get(key)); + let started = time(summary.get("started_at"))?; + let business_key = field("business_key"); + let visibility = document_or_empty(summary.get("visibility_labels")); + + // The instance, reused only within this namespace. + let instance = Self::query("SELECT namespace,current_run_id,run_count,last_message_sequence FROM workflow_instances WHERE id=$1") + .bind(instance_id).fetch_optional(&mut **tx).await.map_err(db)?; + let (current_run, run_count, last_message) = match &instance { + Some(instance) => { + let owner = DB::optional_string(instance, "namespace").map_err(|error| error.to_string())?; + if owner.as_deref() != Some(namespace) { + return Err(format!("Workflow instance [{instance_id}] belongs to another namespace.")); + } + (DB::optional_string(instance, "current_run_id").map_err(|error| error.to_string())?, + DB::number(instance, "run_count").map_err(|error| error.to_string())?, + DB::number(instance, "last_message_sequence").map_err(|error| error.to_string())?) + } + None => { + Self::query("INSERT INTO workflow_instances(id,workflow_class,workflow_type,namespace,business_key,visibility_labels,memo,current_run_id,run_count,last_message_sequence,started_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,NULL,$8,$9,$10,$11,$12)") + .bind(instance_id).bind(summary.workflow_class.as_str()).bind(summary.workflow_type.as_str()).bind(namespace) + .bind(business_key).bind(DB::document(&visibility)).bind(DB::document(&document_or_empty(summary.get("memo")))) + .bind(run_number).bind(message_cursor).bind(DB::optional_time(wall(started))) + .bind(DB::bind_time(started.unwrap_or(imported).wall)).bind(DB::bind_time(imported.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_instances")] += 1; + (None, run_number, message_cursor) + } + }; + Self::check_store_references(tx, bundle, namespace, run_id, instance_id).await?; + + let closed = time(summary.get("closed_at"))?; + let archived = time(summary.get("archived_at"))?; + let progress = time(summary.get("last_progress_at"))?; + let arguments = payload_data("arguments")?; + let output_data = payload_data("output")?; + Self::query("INSERT INTO workflow_runs(id,workflow_instance_id,run_number,workflow_class,workflow_type,namespace,business_key,visibility_labels,status,closed_reason,compatibility,payload_codec,output_payload_codec,arguments,output,connection,queue,last_history_sequence,last_command_sequence,message_cursor_position,started_at,closed_at,archived_at,archive_command_id,archive_reason,last_progress_at,import_source,import_id,import_dedupe_key,import_contract_version,imported_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29,$30,$31,$32,$33)") + .bind(run_id).bind(instance_id).bind(run_number).bind(summary.workflow_class.as_str()).bind(summary.workflow_type.as_str()) + .bind(namespace).bind(business_key).bind(DB::document(&visibility)).bind(summary.status.as_str()) + .bind(field("closed_reason")).bind(field("compatibility")).bind(payload_codec.as_str()).bind(output_codec.as_deref()) + .bind(arguments.as_deref()).bind(output_data.as_deref()).bind(field("connection")).bind(field("queue")) + .bind(history_sequence).bind(last_command_sequence).bind(message_cursor) + .bind(DB::optional_time(wall(started))).bind(DB::optional_time(wall(closed))) + .bind(DB::optional_time(archived.map(|stamp| stamp.utc))).bind(field("archive_command_id")).bind(field("archive_reason")) + .bind(DB::bind_time(progress.unwrap_or(imported).wall)).bind(IMPORT_SOURCE).bind(plan.import_id.as_str()) + .bind(text(bundle.get("dedupe_key"))).bind(CONTRACT_VERSION).bind(DB::bind_time(imported.wall)) + .bind(DB::bind_time(started.unwrap_or(imported).wall)).bind(DB::bind_time(progress.unwrap_or(imported).wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_runs")] += 1; + + // The run becomes current when it says so, or when it closes an + // instance with no current run. + let current = truthy(summary.get("is_current_run")) || (current_run.is_none() && summary.terminal()); + Self::query("UPDATE workflow_instances SET current_run_id=$1,run_count=$2,last_message_sequence=$3,updated_at=$4 WHERE id=$5") + .bind(if current { Some(run_id) } else { current_run.as_deref() }).bind(run_count.max(run_number)) + .bind(last_message.max(message_cursor)).bind(DB::bind_time(imported.wall)).bind(instance_id) + .execute(&mut **tx).await.map_err(db)?; + + for event in &events { + let Some(kind) = enumerated(HISTORY_EVENT_TYPES, event.get("type")) else { + continue; + }; + let recorded = time(event.get("recorded_at"))?; + let created = recorded.unwrap_or_else(stamp_now); + let event_id = text(event.get("id")).map_or_else(id, str::to_owned); + Self::query("INSERT INTO workflow_history_events(id,workflow_run_id,sequence,event_type,payload,workflow_task_id,workflow_command_id,recorded_at,recorded_at_utc,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)") + .bind(event_id).bind(run_id).bind(int(event.get("sequence")).unwrap_or(0)).bind(kind) + .bind(DB::document(&document_or_empty(event.get("payload")))).bind(text(event.get("workflow_task_id"))) + .bind(text(event.get("workflow_command_id"))).bind(DB::optional_time(recorded.map(|stamp| stamp.utc))) + .bind(DB::optional_time(recorded.map(|stamp| stamp.utc))).bind(DB::bind_time(created.wall)).bind(DB::bind_time(created.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_history_events")] += 1; + } + + for command in &commands { + let (Some(kind), Some(status)) = (enumerated(COMMAND_TYPES, command.get("type")), enumerated(COMMAND_STATUSES, command.get("status"))) else { + continue; + }; + let accepted = time(command.get("accepted_at"))?; + let applied = time(command.get("applied_at"))?; + let rejected = time(command.get("rejected_at"))?; + let outcome = cast_enum(COMMAND_OUTCOMES, "CommandOutcome", text(command.get("outcome")))?; + let payload = payload_row_value(command.get("payload"))?; + let created = accepted.unwrap_or_else(stamp_now); + let updated = applied.or(rejected).or(accepted).unwrap_or_else(stamp_now); + Self::query("INSERT INTO workflow_commands(id,workflow_instance_id,workflow_run_id,requested_workflow_run_id,resolved_workflow_run_id,command_type,target_scope,source,context,status,outcome,workflow_class,workflow_type,payload_codec,payload,rejection_reason,command_sequence,message_sequence,accepted_at,applied_at,rejected_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)") + .bind(text(command.get("id")).map_or_else(id, str::to_owned)).bind(instance_id).bind(run_id) + .bind(text(command.get("requested_run_id"))).bind(text(command.get("resolved_run_id")).unwrap_or(run_id)).bind(kind) + .bind(text(command.get("target_scope")).unwrap_or("instance")).bind(text(command.get("source")).unwrap_or("embedded_v2_import")) + .bind(DB::document(&document_or_empty(command.get("context")))).bind(status).bind(outcome) + .bind(Some(summary.workflow_class.as_str()).filter(|value| !value.is_empty())) + .bind(Some(summary.workflow_type.as_str()).filter(|value| !value.is_empty())) + .bind(text(command.get("payload_codec"))).bind(payload.as_deref()).bind(text(command.get("rejection_reason"))) + .bind(DB::optional_integer(int(command.get("sequence")))).bind(DB::optional_integer(int(command.get("message_sequence")))) + .bind(DB::optional_time(wall(accepted))).bind(DB::optional_time(wall(applied))).bind(DB::optional_time(wall(rejected))) + .bind(DB::bind_time(created.wall)).bind(DB::bind_time(updated.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_commands")] += 1; + } + + for signal in list(bundle.get("signals")) { + let Some(status) = enumerated(SIGNAL_STATUSES, signal.get("status")) else { + continue; + }; + let received = time(signal.get("received_at"))?; + let applied = time(signal.get("applied_at"))?; + let rejected = time(signal.get("rejected_at"))?; + let closed = time(signal.get("closed_at"))?; + let outcome = cast_enum(COMMAND_OUTCOMES, "CommandOutcome", text(signal.get("outcome")))?; + let arguments = payload_row_value(signal.get("arguments"))?; + let created = received.unwrap_or_else(stamp_now); + let updated = closed.or(received).unwrap_or_else(stamp_now); + Self::query("INSERT INTO workflow_signal_records(id,workflow_command_id,workflow_instance_id,workflow_run_id,target_scope,requested_workflow_run_id,resolved_workflow_run_id,signal_name,signal_wait_id,status,outcome,rejection_reason,validation_errors,command_sequence,workflow_sequence,payload_codec,arguments,received_at,applied_at,rejected_at,closed_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)") + .bind(text(signal.get("id")).map_or_else(id, str::to_owned)).bind(text(signal.get("command_id")).map_or_else(id, str::to_owned)) + .bind(instance_id).bind(run_id).bind(text(signal.get("target_scope")).unwrap_or("instance")) + .bind(text(signal.get("requested_run_id"))).bind(text(signal.get("resolved_run_id")).unwrap_or(run_id)) + .bind(text(signal.get("name")).unwrap_or("imported-signal")).bind(text(signal.get("signal_wait_id"))).bind(status) + .bind(outcome).bind(text(signal.get("rejection_reason"))).bind(DB::document(&document_or_empty(signal.get("validation_errors")))) + .bind(DB::optional_integer(int(signal.get("command_sequence")))).bind(DB::optional_integer(int(signal.get("workflow_sequence")))) + .bind(text(signal.get("payload_codec"))).bind(arguments.as_deref()) + .bind(DB::optional_time(wall(received))).bind(DB::optional_time(wall(applied))).bind(DB::optional_time(wall(rejected))) + .bind(DB::optional_time(wall(closed))).bind(DB::bind_time(created.wall)).bind(DB::bind_time(updated.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_signal_records")] += 1; + } + + for update in list(bundle.get("updates")) { + let Some(status) = enumerated(UPDATE_STATUSES, update.get("status")) else { + continue; + }; + let accepted = time(update.get("accepted_at"))?; + let applied = time(update.get("applied_at"))?; + let rejected = time(update.get("rejected_at"))?; + let closed = time(update.get("closed_at"))?; + let outcome = cast_enum(COMMAND_OUTCOMES, "CommandOutcome", text(update.get("outcome")))?; + let arguments = payload_row_value(update.get("arguments"))?; + let result = payload_row_value(update.get("result"))?; + let created = accepted.unwrap_or_else(stamp_now); + let updated = closed.or(accepted).unwrap_or_else(stamp_now); + Self::query("INSERT INTO workflow_updates(id,workflow_command_id,workflow_instance_id,workflow_run_id,target_scope,requested_workflow_run_id,resolved_workflow_run_id,update_name,status,outcome,command_sequence,workflow_sequence,payload_codec,arguments,result,validation_errors,rejection_reason,failure_id,accepted_at,applied_at,rejected_at,closed_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24)") + .bind(text(update.get("id")).map_or_else(id, str::to_owned)).bind(text(update.get("command_id")).map_or_else(id, str::to_owned)) + .bind(instance_id).bind(run_id).bind(text(update.get("target_scope")).unwrap_or("instance")) + .bind(text(update.get("requested_run_id"))).bind(text(update.get("resolved_run_id")).unwrap_or(run_id)) + .bind(text(update.get("name")).unwrap_or("imported-update")).bind(status).bind(outcome) + .bind(DB::optional_integer(int(update.get("command_sequence")))).bind(DB::optional_integer(int(update.get("workflow_sequence")))) + .bind(text(update.get("payload_codec"))).bind(arguments.as_deref()).bind(result.as_deref()) + .bind(DB::document(&document_or_empty(update.get("validation_errors")))).bind(text(update.get("rejection_reason"))) + .bind(text(update.get("failure_id"))).bind(DB::optional_time(wall(accepted))).bind(DB::optional_time(wall(applied))) + .bind(DB::optional_time(wall(rejected))).bind(DB::optional_time(wall(closed))) + .bind(DB::bind_time(created.wall)).bind(DB::bind_time(updated.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_updates")] += 1; + } + + for task in list(bundle.get("tasks")) { + let (Some(kind), Some(status)) = (enumerated(TASK_TYPES, task.get("type")), enumerated(TASK_STATUSES, task.get("status"))) else { + continue; + }; + let available = time(task.get("available_at"))?; + let leased = time(task.get("leased_at"))?; + let lease_expires = time(task.get("lease_expires_at"))?; + let dispatch_attempt = time(task.get("last_dispatch_attempt_at"))?; + let dispatched = time(task.get("last_dispatched_at"))?; + let claim_failed = time(task.get("last_claim_failed_at"))?; + let repair_available = time(task.get("repair_available_at"))?; + let created = available.unwrap_or_else(stamp_now); + let updated = dispatched.or(available).unwrap_or_else(stamp_now); + Self::query("INSERT INTO workflow_tasks(id,workflow_run_id,namespace,task_type,status,payload,connection,queue,compatibility,available_at,leased_at,lease_owner,lease_expires_at,attempt_count,repair_count,last_dispatch_attempt_at,last_dispatched_at,last_dispatch_error,last_claim_failed_at,last_claim_error,repair_available_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23)") + .bind(text(task.get("id")).map_or_else(id, str::to_owned)).bind(run_id).bind(namespace).bind(kind).bind(status) + .bind(DB::document(&document_or_empty(task.get("payload")))).bind(text(task.get("connection"))).bind(text(task.get("queue"))) + .bind(text(task.get("compatibility"))).bind(DB::optional_time(wall(available))).bind(DB::optional_time(wall(leased))) + .bind(text(task.get("lease_owner"))).bind(DB::optional_time(wall(lease_expires))) + .bind(int(task.get("attempt_count")).unwrap_or(0)).bind(int(task.get("repair_count")).unwrap_or(0)) + .bind(DB::optional_time(wall(dispatch_attempt))).bind(DB::optional_time(wall(dispatched))) + .bind(text(task.get("last_dispatch_error"))).bind(DB::optional_time(wall(claim_failed))) + .bind(text(task.get("last_claim_error"))).bind(DB::optional_time(wall(repair_available))) + .bind(DB::bind_time(created.wall)).bind(DB::bind_time(updated.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_tasks")] += 1; + } + + let activities = list(bundle.get("activities")); + for activity in &activities { + let (Some(activity_id), Some(status)) = (text(activity.get("id")), enumerated(ACTIVITY_STATUSES, activity.get("status"))) else { + continue; + }; + let created = time(activity.get("created_at"))?; + let started = time(activity.get("started_at"))?; + let closed = time(activity.get("closed_at"))?; + let heartbeat = time(activity.get("last_heartbeat_at"))?; + let class = text(activity.get("activity_class")).or_else(|| text(activity.get("activity_type"))).unwrap_or("imported-activity"); + let kind = text(activity.get("activity_type")).or_else(|| text(activity.get("activity_class"))).unwrap_or("imported-activity"); + let arguments = payload_row_value(activity.get("arguments"))?; + let result = payload_row_value(activity.get("result"))?; + let row_created = created.unwrap_or_else(stamp_now); + let row_updated = closed.or(created).unwrap_or_else(stamp_now); + Self::query("INSERT INTO activity_executions(id,workflow_run_id,sequence,activity_class,activity_type,status,payload_codec,arguments,result,exception,connection,queue,attempt_count,current_attempt_id,retry_policy,parallel_group_path,started_at,closed_at,last_heartbeat_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)") + .bind(activity_id).bind(run_id).bind(int(activity.get("sequence")).unwrap_or(0)).bind(class).bind(kind).bind(status) + .bind(text(activity.get("payload_codec")).unwrap_or(payload_codec.as_str())).bind(arguments.as_deref()).bind(result.as_deref()) + .bind(text(activity.get("exception"))).bind(text(activity.get("connection"))).bind(text(activity.get("queue"))) + .bind(int(activity.get("attempt_count")).unwrap_or(0)).bind(text(activity.get("current_attempt_id"))) + .bind(DB::document(&document_or_empty(activity.get("retry_policy")))) + .bind(DB::document(&document_or_empty(activity.get("parallel_group_path")))) + .bind(DB::optional_time(wall(started))).bind(DB::optional_time(wall(closed))).bind(DB::optional_time(wall(heartbeat))) + .bind(DB::bind_time(row_created.wall)).bind(DB::bind_time(row_updated.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("activity_executions")] += 1; + } + + for activity in &activities { + let Some(activity_id) = text(activity.get("id")) else { + continue; + }; + for attempt in list(activity.get("attempts")) { + let Some(status) = enumerated(ATTEMPT_STATUSES, attempt.get("status")) else { + continue; + }; + let started = time(attempt.get("started_at"))?.unwrap_or_else(stamp_now); + let heartbeat = time(attempt.get("last_heartbeat_at"))?; + let lease_expires = time(attempt.get("lease_expires_at"))?; + let closed = time(attempt.get("closed_at"))?; + Self::query("INSERT INTO activity_attempts(id,workflow_run_id,activity_execution_id,workflow_task_id,worker_attempt_id,attempt_number,status,lease_owner,started_at,last_heartbeat_at,lease_expires_at,closed_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)") + .bind(text(attempt.get("id")).map_or_else(id, str::to_owned)).bind(run_id).bind(activity_id) + .bind(text(attempt.get("workflow_task_id")).or_else(|| text(attempt.get("task_id")))) + .bind(text(attempt.get("worker_attempt_id"))).bind(int(attempt.get("attempt_number")).unwrap_or(1)).bind(status) + .bind(text(attempt.get("lease_owner"))).bind(DB::bind_time(started.wall)).bind(DB::optional_time(wall(heartbeat))) + .bind(DB::optional_time(wall(lease_expires))).bind(DB::optional_time(wall(closed))) + .bind(DB::bind_time(started.wall)).bind(DB::bind_time(closed.unwrap_or(started).wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("activity_attempts")] += 1; + } + } + + for timer in list(bundle.get("timers")) { + let (Some(timer_id), Some(status)) = (text(timer.get("id")), enumerated(TIMER_STATUSES, timer.get("status"))) else { + continue; + }; + let created = time(timer.get("created_at"))?; + let fire = time(timer.get("fire_at"))?; + let fired = time(timer.get("fired_at"))?; + let fire_at = fire.or(created).unwrap_or_else(stamp_now); + let row_created = created.unwrap_or_else(stamp_now); + let row_updated = fired.or(created).unwrap_or_else(stamp_now); + Self::query("INSERT INTO workflow_run_timers(id,workflow_run_id,sequence,status,delay_seconds,fire_at,fired_at,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)") + .bind(timer_id).bind(run_id).bind(int(timer.get("sequence")).unwrap_or(0)).bind(status) + .bind(int(timer.get("delay_seconds")).unwrap_or(0).max(0)).bind(DB::bind_time(fire_at.wall)) + .bind(DB::optional_time(wall(fired))).bind(DB::bind_time(row_created.wall)).bind(DB::bind_time(row_updated.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_run_timers")] += 1; + } + + for failure in list(bundle.get("failures")) { + let created = time(failure.get("created_at"))?.unwrap_or_else(stamp_now); + let category = cast_enum(FAILURE_CATEGORIES, "FailureCategory", text(failure.get("failure_category")))?; + Self::query("INSERT INTO workflow_failures(id,workflow_run_id,source_kind,source_id,propagation_kind,failure_category,non_retryable,handled,exception_class,message,file,line,trace_preview,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,CASE WHEN $7=1 THEN TRUE ELSE FALSE END,CASE WHEN $8=1 THEN TRUE ELSE FALSE END,$9,$10,$11,$12,$13,$14,$15)") + .bind(text(failure.get("id")).map_or_else(id, str::to_owned)).bind(run_id) + .bind(text(failure.get("source_kind")).unwrap_or("workflow")).bind(text(failure.get("source_id")).unwrap_or(run_id)) + .bind(text(failure.get("propagation_kind")).unwrap_or("local")).bind(category) + .bind(i64::from(truthy(failure.get("non_retryable")))).bind(i64::from(truthy(failure.get("handled")))) + .bind(text(failure.get("exception_class")).or_else(|| text(failure.get("exception_type"))).unwrap_or("ImportedWorkflowFailure")) + .bind(text(failure.get("message")).unwrap_or("")).bind(text(failure.get("file")).unwrap_or("")) + .bind(DB::optional_integer(int(failure.get("line")))).bind(text(failure.get("trace_preview"))) + .bind(DB::bind_time(created.wall)).bind(DB::bind_time(created.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_failures")] += 1; + } + + let links = bundle.get("links").filter(|value| value.is_array()).unwrap_or(&empty); + for (side, parent) in [("parents", true), ("children", false)] { + for link in list(links.get(side)) { + let (other_instance, other_run) = if parent { + (text(link.get("parent_workflow_instance_id")), text(link.get("parent_workflow_run_id"))) + } else { + (text(link.get("child_workflow_instance_id")), text(link.get("child_workflow_run_id"))) + }; + let (Some(other_instance), Some(other_run)) = (other_instance, other_run) else { + continue; + }; + let (parent_instance, parent_run, child_instance, child_run) = if parent { + (other_instance, other_run, instance_id, run_id) + } else { + (instance_id, run_id, other_instance, other_run) + }; + let link_id = ulid_value(link.get("id")).map_or_else(id, str::to_owned); + if !Self::import_link_endpoint(tx, namespace, &link_id, other_instance, other_run).await? { + continue; + } + let link_type = text(link.get("type")).unwrap_or(if parent { "parent" } else { "child" }); + let sequence = int(link.get("sequence")); + let primary = truthy(link.get("is_primary_parent")); + let created = time(link.get("created_at"))?.unwrap_or_else(stamp_now); + // A link exported from both its runs is written once; any + // other difference fails the import. + if let Some(existing) = Self::query("SELECT link_type,sequence,parent_workflow_instance_id,parent_workflow_run_id,child_workflow_instance_id,child_workflow_run_id,CASE WHEN is_primary_parent THEN 1 ELSE 0 END AS primary_flag FROM workflow_links WHERE id=$1") + .bind(link_id.as_str()).fetch_optional(&mut **tx).await.map_err(db)? + { + let read = |name: &str| DB::optional_string(&existing, name).map_err(|error| error.to_string()); + let checks: [(&str, bool); 7] = [ + ("link_type", read("link_type")?.as_deref() == Some(link_type)), + ("sequence", DB::optional_number(&existing, "sequence").map_err(|error| error.to_string())? == sequence), + ("parent_workflow_instance_id", read("parent_workflow_instance_id")?.as_deref() == Some(parent_instance)), + ("parent_workflow_run_id", read("parent_workflow_run_id")?.as_deref() == Some(parent_run)), + ("child_workflow_instance_id", read("child_workflow_instance_id")?.as_deref() == Some(child_instance)), + ("child_workflow_run_id", read("child_workflow_run_id")?.as_deref() == Some(child_run)), + ("is_primary_parent", (DB::number(&existing, "primary_flag").map_err(|error| error.to_string())? != 0) == primary), + ]; + if let Some((field, _)) = checks.iter().find(|(_, same)| !same) { + return Err(format!("Imported workflow link [{link_id}] conflicts on [{field}].")); + } + continue; + } + Self::query("INSERT INTO workflow_links(id,link_type,sequence,parent_workflow_instance_id,parent_workflow_run_id,child_workflow_instance_id,child_workflow_run_id,is_primary_parent,created_at,updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,CASE WHEN $8=1 THEN TRUE ELSE FALSE END,$9,$10)") + .bind(link_id.as_str()).bind(link_type).bind(DB::optional_integer(sequence)).bind(parent_instance).bind(parent_run) + .bind(child_instance).bind(child_run).bind(i64::from(primary)) + .bind(DB::bind_time(created.wall)).bind(DB::bind_time(created.wall)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_links")] += 1; + } + } + + let written = now(); + for memo in plan.memos.as_ref().map_err(Clone::clone)? { + Self::query(DB::MEMO_UPSERT) + .bind(run_id).bind(instance_id).bind(memo.key.as_str()).bind(DB::document(&memo.value)).bind(history_sequence) + .bind(DB::bind_time(written)).bind(DB::bind_time(written)).bind(DB::document(&memo.portable)).bind(history_sequence) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_memos")] += 1; + } + + for attribute in plan.attributes.as_ref().map_err(Clone::clone)? { + use super::search_attribute_upserts::Typed; + let value = &attribute.value; + let text_of = |kind| match value { + Typed::String(text) if kind == "string" => Some(text.as_str()), + Typed::Keyword(text) if kind == "keyword" => Some(text.as_str()), + _ => None, + }; + Self::query(DB::SEARCH_ATTRIBUTE_UPSERT) + .bind(run_id).bind(instance_id).bind(attribute.key.as_str()).bind(value.kind()) + .bind(text_of("string")).bind(text_of("keyword")) + .bind(match value { + Typed::KeywordList(items) => Some(DB::document(&json!(items))), + _ => None, + }) + .bind(DB::optional_integer(match value { + Typed::Int(number) => Some(*number), + _ => None, + })) + .bind(DB::optional_float_value(match value { + Typed::Float(number) => Some(*number), + _ => None, + })) + .bind(DB::optional_flag(match value { + Typed::Bool(flag) => Some(*flag), + _ => None, + })) + .bind(DB::optional_time(match value { + Typed::Datetime(time) => Some(*time), + _ => None, + })) + .bind(history_sequence).bind(DB::bind_time(written)).bind(DB::bind_time(written)) + .execute(&mut **tx).await.map_err(db)?; + rows[row("workflow_search_attributes")] += 1; + } + rows[row("workflow_run_summaries")] = 1; + Ok(rows) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bundle(text: &str) -> PhpArray { + php_value::request_body(text.as_bytes()) + } + + #[test] + fn the_controller_unwraps_bundles_and_reads_options_like_laravel() { + assert!(matches!(request(b"{}", &[]), Request::Invalid)); + assert!(matches!(request(br#"{"bundle":{}}"#, &[]), Request::Invalid)); + assert!(matches!(request(b"null", &[]), Request::Invalid)); + let Request::Import(bundle, options) = request(br#"{"bundle":{"a":1},"dry_run":"yes","import_id":" i-1 ","require_signature":"no","signing_key":" "}"#, &[]) else { + panic!("an import"); + }; + assert!(bundle.contains("a") && options.dry_run && !options.require_signature); + assert_eq!(options.import_id.as_deref(), Some("i-1")); + assert_eq!(options.signing_key, None); + // A string bundle leaves the whole body as the bundle. + let Request::Import(bundle, options) = request(br#"{"bundle":"x","import_id":12345}"#, &[("dry_run".into(), "1".into())]) else { + panic!("an import"); + }; + assert!(bundle.contains("import_id") && options.dry_run && options.import_id.is_none()); + // The signing key is read only from the body; in the URL it refuses + // the request, in any spelling PHP would read. + let Request::Import(_, options) = request(br#"{"bundle":{"a":1},"signing_key":" k1 "}"#, &[("dry_run".into(), "1".into())]) else { + panic!("an import"); + }; + assert_eq!(options.signing_key.as_deref(), Some("k1")); + for key in ["signing_key", "signing_key[]", "signing_key[0]"] { + assert!(matches!(request(br#"{"bundle":{"a":1}}"#, &[(key.into(), "k1".into())]), Request::KeyInQuery)); + } + } + + #[test] + fn eligibility_follows_php_s_order_and_quirks() { + let body = bundle(r#"{"workflow":{"status":"completed"},"history_complete":1,"redaction":{"paths":["payloads.arguments.data"]}, + "payloads":{"codec":"json","output":{"codec":5}},"tasks":[{"status":"leased"}],"activities":[{"attempts":[{"status":"running"}]}]}"#); + let summary = Summary::of(&body, "ns"); + let options = Options { dry_run: false, import_id: None, require_signature: true, signing_key: None }; + let rules: Vec = eligibility(&body, "warning", false, &summary, &options).errors.iter() + .map(|error| error["rule"].as_str().unwrap().to_owned()).collect(); + assert_eq!(rules, ["bundle.signature_required", "workflow.instance_id_missing", "workflow.run_id_missing", + "workflow.terminal_history_incomplete", "workflow.source_runtime_unsupported", "payload_codec.unsupported", + "payload_codec.unsupported", "tasks.leased_task_present"]); + let messages = eligibility(&body, "ok", true, &summary, &options).errors; + assert!(messages[5]["message"].as_str().unwrap().contains("at [payloads.output.codec] must be \"avro\"; found int.")); + assert_eq!(estimated_rows(&Php::Array(body))[row("workflow_tasks")], 1); + } + + #[test] + fn times_parse_like_carbon_for_exported_forms() { + let stamp = parse_time("2026-10-24T13:00:00.123456+02:00").unwrap(); + assert_eq!(stamp.wall.to_rfc3339(), "2026-10-24T13:00:00.123456+00:00"); + assert_eq!(stamp.utc.to_rfc3339(), "2026-10-24T11:00:00.123456+00:00"); + assert!(parse_time("2026-10-24 13:00:00").is_ok()); + assert!(parse_time("not a time").unwrap_err().starts_with("Could not parse 'not a time'")); + } +} diff --git a/rust/tests/execution.rs b/rust/tests/execution.rs index d3352e967..8d41575e4 100644 --- a/rust/tests/execution.rs +++ b/rust/tests/execution.rs @@ -307,6 +307,9 @@ mod route_coverage; #[path = "execution/worker_sessions.rs"] mod worker_sessions; +#[path = "execution/workflow_import_fences.rs"] +mod workflow_import_fences; + enum TestDatabase { Sqlite(tempfile::TempDir), Postgres { diff --git a/rust/tests/execution/workflow_import_fences.rs b/rust/tests/execution/workflow_import_fences.rs new file mode 100644 index 000000000..2bc087512 --- /dev/null +++ b/rust/tests/execution/workflow_import_fences.rs @@ -0,0 +1,509 @@ +//! Server #765: an import into one namespace never reads, links or updates +//! another namespace's rows; the import route admits only admins; and the +//! signing key never travels in the URL. Each test runs on the selected +//! execution backend (SQLite, PostgreSQL or MySQL). +use super::*; +use sha2::{Digest, Sha256}; + +const FIXTURE: &str = include_str!("../../../tests/Fixtures/ServerParity/workflow-import.json"); +const ROUTE: &str = "/api/workflows/import/embedded-v2"; +const CREATED_AT: &str = "2026-10-24T14:45:59.849817Z"; + +/// PHP's canonical bundle JSON (`json-recursive-ksort-v1` under +/// `JSON_UNESCAPED_SLASHES`): maps sorted by key, an empty map as `[]`, +/// and every non-ASCII character as UTF-16 escapes. +fn canonical(value: &Value) -> String { + fn string(text: &str) -> String { + let mut out = String::from('"'); + for character in text.chars() { + match character { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\u{8}' => out.push_str("\\b"), + '\u{c}' => out.push_str("\\f"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + character if (character as u32) < 0x20 || (character as u32) > 0x7f => { + let mut units = [0u16; 2]; + for unit in character.encode_utf16(&mut units) { + out.push_str(&format!("\\u{unit:04x}")); + } + } + character => out.push(character), + } + } + out.push('"'); + out + } + match value { + Value::Object(map) if map.is_empty() => "[]".into(), + Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + let entries: Vec = keys.iter().map(|key| format!("{}:{}", string(key), canonical(&map[*key]))).collect(); + format!("{{{}}}", entries.join(",")) + } + Value::Array(items) => format!("[{}]", items.iter().map(canonical).collect::>().join(",")), + Value::String(text) => string(text), + other => other.to_string(), + } +} + +/// A bundle of the fixture's `template` with fresh identities: each `{u:N}` +/// label a new lowercase id and each `{U:N}` an uppercase one (unless +/// `fixed` names it), `{instance}` the given instance, and the checksum +/// recomputed after `mutate`. +fn bundle(template: &str, instance: &str, fixed: &[(&str, &str)], mutate: impl FnOnce(&mut Value)) -> Value { + let fixture: Value = serde_json::from_str(FIXTURE).unwrap(); + let text = fixture["workflow_import"]["bundles"][template].to_string(); + let salt = ulid::Ulid::new().to_string()[..12].to_owned(); + let label = regex_lite::Regex::new(r"\{([uU]):(\d+)\}|\{instance\}").unwrap(); + let text = label.replace_all(&text, |found: ®ex_lite::Captures| { + let whole = &found[0]; + if whole == "{instance}" { + return instance.to_owned(); + } + if let Some((_, to)) = fixed.iter().find(|(from, _)| *from == whole) { + return (*to).to_owned(); + } + let id = format!("{salt}{:014}", found[2].parse::().unwrap()); + if &found[1] == "U" { id.to_uppercase() } else { id.to_lowercase() } + }); + let mut document: Value = serde_json::from_str(&text).unwrap(); + mutate(&mut document); + document.as_object_mut().unwrap().remove("integrity"); + let checksum: String = Sha256::digest(canonical(&document).as_bytes()).iter().map(|byte| format!("{byte:02x}")).collect(); + document["integrity"] = json!({"canonicalization":"json-recursive-ksort-v1","checksum_algorithm":"sha256", + "checksum":checksum,"signature_algorithm":null,"signature":null,"key_id":null}); + document +} + +fn fresh_id() -> String { + ulid::Ulid::new().to_string().to_lowercase() +} + +fn run_of(document: &Value) -> String { + document["workflow"]["run_id"].as_str().unwrap().to_owned() +} + +/// A lineage entry naming `other` as the related run on `side`. +fn link(side: &str, other_instance: &str, other_run: &str) -> Value { + let other = if side == "parents" { "parent" } else { "child" }; + let mut entry = json!({"id":fresh_id(),"type":if side == "parents" { "child" } else { "continue_as_new" }, + "sequence":1,"is_primary_parent":true,"created_at":CREATED_AT}); + entry[format!("{other}_workflow_instance_id")] = json!(other_instance); + entry[format!("{other}_workflow_run_id")] = json!(other_run); + entry +} + +async fn import(app: &Router, namespace: &str, document: &Value) -> (StatusCode, Value) { + namespaces::scoped(app, namespace, "POST", ROUTE, json!({"bundle": document})).await +} + +/// Everything a namespace shows of one run: its exported bundle, without +/// the export time and the checksum over it. +async fn snapshot(app: &Router, namespace: &str, workflow: &str, run: &str) -> Value { + let path = format!("/api/workflows/{workflow}/runs/{run}/history/export"); + let (status, mut export) = namespaces::scoped(app, namespace, "GET", &path, Value::Null).await; + assert_eq!(status, StatusCode::OK, "{export}"); + for key in ["exported_at", "integrity"] { + export.as_object_mut().unwrap().remove(key); + } + export +} + +async fn absent(app: &Router, namespace: &str, workflow: &str, run: &str) { + let path = format!("/api/workflows/{workflow}/runs/{run}"); + let (status, body) = namespaces::scoped(app, namespace, "GET", &path, Value::Null).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); +} + +fn refused(report: &Value, rule: &str, text: &str) { + assert_eq!(report["status"], "rejected", "{report}"); + assert!(report["rows"].as_object().unwrap().values().all(|count| count == 0), "{report}"); + let errors = report["eligibility"]["errors"].as_array().unwrap(); + assert!(errors.iter().any(|error| error["rule"] == rule && error["message"].as_str().unwrap().contains(text)), + "{rule} / {text}: {report}"); +} + +impl TestDatabase { + /// Rewrites a stored row the way no request can, binding the `$n` + /// placeholders in order (as `?` on MySQL). + async fn forge(&self, sql: &'static str, binds: &[&str]) -> u64 { + match self { + Self::Sqlite(dir) => { + let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", dir.path().join("runtime.sqlite").display())) + .await + .unwrap(); + let mut query = sqlx::query(sql); + for bind in binds { + query = query.bind(*bind); + } + let affected = query.execute(&pool).await.unwrap().rows_affected(); + pool.close().await; + affected + } + Self::Postgres { options, .. } => { + let pool = sqlx::postgres::PgPoolOptions::new().connect_with(options.as_ref().clone()).await.unwrap(); + let mut query = sqlx::query(sql); + for bind in binds { + query = query.bind(*bind); + } + let affected = query.execute(&pool).await.unwrap().rows_affected(); + pool.close().await; + affected + } + Self::MySql { options, .. } => { + let sql = regex_lite::Regex::new(r"\$\d+").unwrap().replace_all(sql, "?").into_owned(); + let pool = sqlx::mysql::MySqlPoolOptions::new().connect_with(options.as_ref().clone()).await.unwrap(); + let mut query = sqlx::query(sqlx::AssertSqlSafe(sql)); + for bind in binds { + query = query.bind(*bind); + } + let affected = query.execute(&pool).await.unwrap().rows_affected(); + pool.close().await; + affected + } + } + } +} + +#[tokio::test] +async fn lineage_into_another_namespace_is_refused_in_both_directions() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + namespaces::create(&app, "beta").await; + let foreign = namespaces::start_scoped(&app, "beta", "beta-lineage").await; + let foreign_run = foreign["run_id"].as_str().unwrap().to_owned(); + let before = snapshot(&app, "beta", "beta-lineage", &foreign_run).await; + // Beta's run as a parent, as a child, and beta's instance with a run + // that does not exist yet: each fails the whole import. + for (index, (side, other_run, message)) in [ + ("parents", foreign_run.as_str(), "names a run of another namespace"), + ("children", foreign_run.as_str(), "names a run of another namespace"), + ("parents", "01ksnotyetimported000000000", "names an instance of another namespace"), + ] + .into_iter() + .enumerate() + { + let instance = format!("wi-foreign-lineage-{index}"); + let document = bundle("terminated", &instance, &[], |document| { + document["links"][side] = json!([link(side, "beta-lineage", other_run)]); + }); + let (status, report) = import(&app, "default", &document).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{report}"); + refused(&report, "target.write_failed", message); + absent(&app, "default", &instance, &run_of(&document)).await; + } + // The imported run itself as its own parent. + let document = bundle("terminated", "wi-self-lineage", &[], |document| { + let run = run_of(document); + document["links"]["parents"] = json!([link("parents", "wi-self-lineage", &run)]); + }); + let (status, report) = import(&app, "default", &document).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{report}"); + refused(&report, "references.self_link", "names the imported run as its own parent"); + absent(&app, "default", "wi-self-lineage", &run_of(&document)).await; + assert_eq!(snapshot(&app, "beta", "beta-lineage", &foreign_run).await, before); +} + +#[tokio::test] +async fn lineage_links_only_existing_runs_of_one_namespace() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + namespaces::create(&app, "beta").await; + // A parent and its child in one namespace: the link is written. + let parent = bundle("terminated", "wi-parent", &[], |_| {}); + let (status, report) = import(&app, "default", &parent).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + let parent_run = run_of(&parent); + let child = bundle("terminated", "wi-child", &[], |document| { + document["links"]["parents"] = json!([link("parents", "wi-parent", &parent_run)]); + }); + let (status, report) = import(&app, "default", &child).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + assert_eq!(report["rows"]["workflow_links"], 1, "{report}"); + let exported = snapshot(&app, "default", "wi-child", &run_of(&child)).await; + assert_eq!(exported["links"]["parents"][0]["parent_workflow_run_id"], json!(parent_run), "{exported}"); + // A parent no namespace holds yet: the child imports without the link, + // which waits for the parent's own import. + let pending_run = fresh_id(); + let orphan = bundle("terminated", "wi-orphan", &[], |document| { + document["links"]["parents"] = json!([link("parents", "wi-later-parent", &pending_run)]); + }); + let (status, report) = import(&app, "default", &orphan).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + assert_eq!(report["rows"]["workflow_links"], 0, "{report}"); + let orphan_before = snapshot(&app, "default", "wi-orphan", &run_of(&orphan)).await; + assert_eq!(orphan_before["links"]["parents"], json!([]), "{orphan_before}"); + // That run, imported later into another namespace, is never linked. + let later = bundle("terminated", "wi-later-parent", &[("{u:1}", pending_run.as_str())], |_| {}); + let (status, report) = import(&app, "beta", &later).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + assert_eq!(snapshot(&app, "default", "wi-orphan", &run_of(&orphan)).await, orphan_before); + let foreign = snapshot(&app, "beta", "wi-later-parent", &pending_run).await; + assert_eq!((foreign["links"]["parents"].clone(), foreign["links"]["children"].clone()), (json!([]), json!([])), "{foreign}"); +} + +// Two bundles that each carry the same parent/child link, imported at the +// same moment: native serializes durable transactions, so whichever imports +// second sees the first run committed and writes the link exactly once. +#[tokio::test] +async fn reciprocal_imports_at_the_same_moment_write_their_link_once() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let other = database.open().await.unwrap(); + let (first, second) = (router(runtime), router(other)); + let (parent_run, child_run, link_id) = (fresh_id(), fresh_id(), ulid::Ulid::new().to_string()); + let entry = |side: &str, instance: &str, run: &str| { + let mut entry = link(side, instance, run); + entry["id"] = json!(link_id); + entry["type"] = json!("child"); + entry + }; + let parent = bundle("terminated", "wi-reciprocal-parent", &[("{u:1}", parent_run.as_str())], |document| { + document["links"]["children"] = json!([entry("children", "wi-reciprocal-child", &child_run)]); + }); + let child = bundle("terminated", "wi-reciprocal-child", &[("{u:1}", child_run.as_str())], |document| { + document["links"]["parents"] = json!([entry("parents", "wi-reciprocal-parent", &parent_run)]); + }); + let ((parent_status, parent_report), (child_status, child_report)) = + tokio::join!(import(&first, "default", &parent), import(&second, "default", &child)); + assert_eq!(parent_status, StatusCode::CREATED, "{parent_report}"); + assert_eq!(child_status, StatusCode::CREATED, "{child_report}"); + let written = parent_report["rows"]["workflow_links"].as_i64().unwrap() + child_report["rows"]["workflow_links"].as_i64().unwrap(); + assert_eq!(written, 1, "{parent_report} {child_report}"); + let exported = snapshot(&first, "default", "wi-reciprocal-child", &child_run).await; + let parents = exported["links"]["parents"].as_array().unwrap(); + assert_eq!(parents.len(), 1, "{exported}"); + assert_eq!((parents[0]["id"].clone(), parents[0]["parent_workflow_run_id"].clone()), (json!(link_id), json!(parent_run))); +} + +// PHP's own answers come first: an ineligible bundle reports only its +// eligibility findings, and a bundle whose run already exists reports the +// run conflict, whatever its references say. The fence then refuses before a +// dry run or a write. +#[tokio::test] +async fn php_s_eligibility_and_run_checks_answer_before_the_reference_fence() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let existing = bundle("terminated", "wi-existing", &[], |_| {}); + assert_eq!(import(&app, "default", &existing).await.0, StatusCode::CREATED); + let existing_run = run_of(&existing); + // The run is renamed to the existing one; every reference still names + // the bundle's own run. + let colliding = bundle("terminated", "wi-colliding", &[], |document| document["workflow"]["run_id"] = json!(existing_run)); + let (status, report) = namespaces::scoped(&app, "default", "POST", ROUTE, json!({"bundle": colliding, "dry_run": true})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{report}"); + let rules: Vec<&str> = report["eligibility"]["errors"].as_array().unwrap().iter().map(|error| error["rule"].as_str().unwrap()).collect(); + assert_eq!(rules, ["target.run_id_conflict"], "{report}"); + let anonymous = bundle("terminated", "wi-anonymous", &[], |document| { + let workflow = document["workflow"].as_object_mut().unwrap(); + workflow.remove("instance_id"); + workflow.remove("run_id"); + }); + let (_, report) = import(&app, "default", &anonymous).await; + let rules: Vec<&str> = report["eligibility"]["errors"].as_array().unwrap().iter().map(|error| error["rule"].as_str().unwrap()).collect(); + assert!(rules.contains(&"workflow.run_id_missing") && !rules.iter().any(|rule| rule.starts_with("references.")), "{report}"); + // A dry run of a bundle the fence refuses is refused too. + let crafted = bundle("terminated", "wi-crafted", &[], |document| document["commands"][0]["resolved_run_id"] = json!(existing_run)); + let (status, report) = namespaces::scoped(&app, "default", "POST", ROUTE, json!({"bundle": crafted, "dry_run": true})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{report}"); + refused(&report, "references.foreign_run", "commands.0.resolved_run_id must name the imported run"); +} + +#[tokio::test] +async fn foreign_row_references_are_refused_before_anything_is_written() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + namespaces::create(&app, "beta").await; + let foreign = namespaces::start_scoped(&app, "beta", "beta-rows").await; + let foreign_run = foreign["run_id"].as_str().unwrap().to_owned(); + let terminate = format!("/api/workflows/beta-rows/runs/{foreign_run}/terminate"); + let (status, body) = namespaces::scoped(&app, "beta", "POST", &terminate, json!({"reason":"fence"})).await; + assert!(status.is_success(), "{body}"); + let before = snapshot(&app, "beta", "beta-rows", &foreign_run).await; + let foreign_command = before["commands"][0]["id"].as_str().unwrap().to_owned(); + let foreign_failure = before["failures"][0]["id"].as_str().expect("termination records a failure").to_owned(); + type Mutation = Box; + let id = |value: &str| json!(value); + let command = foreign_command.clone(); + let update = move |command_id: Value, failure_id: Value| { + json!([{"id":fresh_id(),"command_id":command_id,"status":"failed","name":"set","command_sequence":3, + "failure_id":failure_id,"accepted_at":CREATED_AT,"closed_at":CREATED_AT}]) + }; + let cases: Vec<(&str, Mutation, &str, &str)> = vec![ + ("signal command", Box::new({ let command = command.clone(); move |d: &mut Value| d["signals"][0]["command_id"] = id(&command) }), + "references.unresolved", "signals.0.command_id must name a signal command"), + ("update command", Box::new({ let command = command.clone(); move |d: &mut Value| d["updates"] = update(id(&command), Value::Null) }), + "references.unresolved", "updates.0.command_id must name an update command"), + ("activity task", Box::new({ let command = command.clone(); move |d: &mut Value| d["tasks"][1]["payload"]["activity_execution_id"] = id(&command) }), + "references.unresolved", "tasks.1.payload.activity_execution_id must name an activity"), + ("current attempt", Box::new({ let command = command.clone(); move |d: &mut Value| d["activities"][0]["current_attempt_id"] = id(&command) }), + "references.unresolved", "activities.0.current_attempt_id must name one of the activity's own attempts"), + ("event command", Box::new({ let command = command.clone(); move |d: &mut Value| d["history_events"][0]["workflow_command_id"] = id(&command) }), + "references.unresolved", "history_events.0.workflow_command_id must name a command"), + ("resolved run", Box::new({ let run = foreign_run.clone(); move |d: &mut Value| d["commands"][1]["resolved_run_id"] = id(&run) }), + "references.foreign_run", "commands.1.resolved_run_id must name the imported run"), + ("requested run", Box::new({ let run = foreign_run.clone(); move |d: &mut Value| d["commands"][1]["requested_run_id"] = id(&run) }), + "target.write_failed", "commands.1.requested_run_id is not a run of this workflow in this namespace"), + // The bundle's own failed update, naming beta's termination failure. + ("update failure", Box::new({ let failure = foreign_failure.clone(); move |d: &mut Value| { + let command_id = fresh_id(); + d["commands"].as_array_mut().unwrap().push(json!({"id":command_id,"sequence":3,"type":"update", + "target_scope":"instance","status":"accepted","accepted_at":CREATED_AT})); + d["updates"] = update(json!(command_id), id(&failure)); + } }), "target.write_failed", "updates.0.failure_id names a failure of another run"), + ]; + for (index, (case, mutation, rule, text)) in cases.into_iter().enumerate() { + let instance = format!("wi-foreign-rows-{index}"); + let document = bundle("rich", &instance, &[], |document| mutation(document)); + let (status, report) = import(&app, "default", &document).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{case}: {report}"); + refused(&report, rule, text); + absent(&app, "default", &instance, &run_of(&document)).await; + } + // The untouched template imports, so each refusal came from its reference. + let document = bundle("rich", "wi-foreign-rows-control", &[], |_| {}); + let (status, report) = import(&app, "default", &document).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + assert_eq!(snapshot(&app, "beta", "beta-rows", &foreign_run).await, before); +} + +/// Beta's run and its start command, which the forged rows below name. +/// Beta's run and its accepted, unapplied command of `kind`: the first +/// message of its run, as the forged row's own command is of its run, so +/// only the command's workflow tells them apart. +async fn foreign_target(app: &Router, workflow: &str, kind: &str) -> (String, String, Value) { + namespaces::create(app, "beta").await; + let int = json!({"name":"value","type":"int","required":true,"variadic":false,"default_available":false,"allows_null":false}); + let (status, body) = namespaces::scoped(app, "beta", "POST", "/api/worker/register", json!({ + "worker_id":"beta-worker","task_queue":"test","runtime":"php","supported_workflow_types":["echo"], + "supported_activity_types":[],"capabilities":["workflow_updates"],"workflow_command_contracts":{"echo":{ + "signals":["finish"],"signal_contracts":[{"name":"finish","parameters":[int]}], + "updates":["set"],"update_validators":[],"update_contracts":[{"name":"set","parameters":[int]}]}}})).await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + let foreign = namespaces::start_scoped(app, "beta", workflow).await; + let run = foreign["run_id"].as_str().unwrap().to_owned(); + let (status, polled) = namespaces::scoped(app, "beta", "POST", "/api/worker/workflow-tasks/poll", + json!({"worker_id":"beta-worker","task_queue":"test","timeout_seconds":0})).await; + assert_eq!(status, StatusCode::OK, "{polled}"); + let task = &polled["task"]; + let complete = format!("/api/worker/workflow-tasks/{}/complete", task["task_id"].as_str().unwrap()); + let (status, body) = namespaces::scoped(app, "beta", "POST", &complete, + completion(task, json!([{"type":"open_signal_wait","signal_name":"finish"}]))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let input = envelope(Payload::Array(vec![Payload::Long(5)])); + let (path, body) = match kind { + "update" => (format!("/api/workflows/{workflow}/runs/{run}/update/set"), json!({"input":input,"wait_for":"accepted"})), + _ => (format!("/api/workflows/{workflow}/runs/{run}/signal/finish"), json!({"input":input})), + }; + let (status, accepted) = namespaces::scoped(app, "beta", "POST", &path, body).await; + assert_eq!(status, StatusCode::ACCEPTED, "{accepted}"); + let before = snapshot(app, "beta", workflow, &run).await; + let stored = before["commands"].as_array().unwrap().iter().find(|row| row["type"] == json!(kind)).unwrap().clone(); + assert_eq!((stored["message_sequence"].clone(), stored["applied_at"].clone()), (json!(1), Value::Null), "{before}"); + (run, stored["id"].as_str().unwrap().to_owned(), before) +} + +// Defense in depth for rows no import can now write: a signal record that +// names another namespace's command is refused when it applies, and the +// foreign command stays as it was. +#[tokio::test] +async fn a_signal_never_applies_to_another_namespaces_command() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let (foreign_run, foreign_command, before) = foreign_target(&app, "beta-signal-target", "update").await; + update_worker(&app, "forged-signal-worker").await; + let started = start(&app, "forged-signal").await; + let run = started["run_id"].as_str().unwrap().to_owned(); + open_update_wait(&app, "forged-signal-worker").await; + let path = format!("/api/workflows/forged-signal/runs/{run}/signal/finish"); + let (status, signal) = request(&app, "POST", &path, json!({"input":envelope(Payload::Array(vec![Payload::Long(7)]))})).await; + assert_eq!(status, StatusCode::ACCEPTED, "{signal}"); + let forged = database.forge("UPDATE workflow_signal_records SET workflow_command_id=$1 WHERE workflow_run_id=$2", + &[&foreign_command, &run]).await; + assert_eq!(forged, 1); + let task = poll(&app, "forged-signal-worker", "workflow").await; + assert!(task["workflow_signal_id"].is_string(), "{task}"); + let (status, refusal) = finish_task(&app, &task, json!([{"type":"complete_workflow","result":envelope(Payload::Long(1))}])).await; + assert_eq!(status, StatusCode::CONFLICT, "{refusal}"); + assert_eq!(refusal["reason"], "signal_command_unavailable", "{refusal}"); + assert_eq!(snapshot(&app, "beta", "beta-signal-target", &foreign_run).await, before); +} + +#[tokio::test] +async fn an_update_never_applies_to_another_namespaces_command() { + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let (foreign_run, foreign_command, before) = foreign_target(&app, "beta-update-target", "signal").await; + update_worker(&app, "forged-update-worker").await; + let started = start(&app, "forged-update").await; + let run = started["run_id"].as_str().unwrap().to_owned(); + open_update_wait(&app, "forged-update-worker").await; + let path = format!("/api/workflows/forged-update/runs/{run}/update/set"); + let (status, admitted) = request(&app, "POST", &path, + json!({"input":envelope(Payload::Array(vec![Payload::Long(7)])),"wait_for":"accepted"})).await; + assert_eq!(status, StatusCode::ACCEPTED, "{admitted}"); + let update_id = admitted["update_id"].as_str().unwrap().to_owned(); + let forged = database.forge("UPDATE workflow_updates SET workflow_command_id=$1 WHERE id=$2", &[&foreign_command, &update_id]).await; + assert_eq!(forged, 1); + let task = poll(&app, "forged-update-worker", "workflow").await; + assert_eq!(task["workflow_update_id"], json!(update_id), "{task}"); + let commands = json!([{"type":"complete_update","update_id":update_id,"result":envelope(Payload::Long(1))}]); + let (status, refusal) = finish_task(&app, &task, commands).await; + assert_eq!(status, StatusCode::CONFLICT, "{refusal}"); + assert_eq!(refusal["reason"], "update_command_unavailable", "{refusal}"); + assert_eq!(snapshot(&app, "beta", "beta-update-target", &foreign_run).await, before); +} + +#[tokio::test] +async fn only_admin_tokens_reach_the_import_controller() { + let database = TestDatabase::new().await; + let runtime = database.open().await.unwrap(); + let app = router(runtime.with_role_tokens( + Some("worker-token".into()), + Some("operator-token".into()), + Some("admin-token".into()), + )); + let document = bundle("terminated", "wi-roles", &[], |_| {}); + let run = run_of(&document); + let describe = format!("/api/workflows/wi-roles/runs/{run}"); + for token in ["worker-token", "operator-token"] { + let (status, _, body) = admission::admission_http(&app, "POST", ROUTE, false, Some(token), Some("2"), + Some("default"), json!({"bundle": document})).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{token}: {body}"); + // The guard answered: no import report, nothing written. + assert!(body.get("schema").is_none() && body.get("rows").is_none(), "{token}: {body}"); + let (status, _, body) = admission::admission_http(&app, "GET", &describe, false, Some("admin-token"), Some("2"), + Some("default"), Value::Null).await; + assert_eq!(status, StatusCode::NOT_FOUND, "{token}: {body}"); + } + let (status, _, report) = admission::admission_http(&app, "POST", ROUTE, false, Some("admin-token"), Some("2"), + Some("default"), json!({"bundle": document})).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + assert_eq!(report["status"], "imported", "{report}"); +} + +#[tokio::test] +async fn the_signing_key_is_refused_in_the_url_and_never_repeated() { + const SENTINEL: &str = "sentinel-signing-key-f3a9c1"; + let database = TestDatabase::new().await; + let app = router(database.open().await.unwrap()); + let document = bundle("terminated", "wi-signing-key", &[], |_| {}); + let run = run_of(&document); + for query in [format!("?signing_key={SENTINEL}"), format!("?dry_run=1&signing_key%5B%5D={SENTINEL}")] { + let (status, body) = namespaces::scoped(&app, "default", "POST", &format!("{ROUTE}{query}"), + json!({"bundle": document})).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{body}"); + assert_eq!(body["reason"], "signing_key_in_query", "{body}"); + assert!(!body.to_string().contains(SENTINEL), "{body}"); + absent(&app, "default", "wi-signing-key", &run).await; + } + // From the body the key is read, and the report never repeats it. + let (status, report) = namespaces::scoped(&app, "default", "POST", ROUTE, + json!({"bundle": document, "signing_key": SENTINEL})).await; + assert_eq!(status, StatusCode::CREATED, "{report}"); + assert!(!report.to_string().contains(SENTINEL), "{report}"); +} diff --git a/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt b/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt index 63b6590ca..75af9ff47 100644 --- a/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt +++ b/rust/tests/fixtures/unserved-php-routes.v2.11.1.txt @@ -32,7 +32,6 @@ POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/acknowledg POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/control POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/heartbeat POST /api/worker/workflow-tasks/{taskId}/local-activities/{attemptId}/outcome -POST /api/workflows/import/embedded-v2 POST /api/workflows/import/waterline-v1 GET /api/workflows/{workflowId}/nexus-operations GET /api/workflows/{workflowId}/runs/{runId}/nexus-operations diff --git a/scripts/conformance/server-parity/contract.mjs b/scripts/conformance/server-parity/contract.mjs index 8610a18fb..eee792f66 100644 --- a/scripts/conformance/server-parity/contract.mjs +++ b/scripts/conformance/server-parity/contract.mjs @@ -19,6 +19,7 @@ import {checkWorkerSessions} from './worker-sessions-contract.mjs'; import {checkWorkerSessionActivities} from './worker-session-activities-contract.mjs'; import {checkActivityCancellationAcknowledgement} from './activity-cancellation-ack-contract.mjs'; import {checkServiceCatalog} from './service-catalog-contract.mjs'; +import {checkWorkflowImport} from './workflow-import-contract.mjs'; import {checkSystemMetrics} from './system-metrics-contract.mjs'; import {checkOperatorDashboardTypes} from './operator-dashboard-types-contract.mjs'; import {checkPrometheusMetrics} from './prometheus-metrics-contract.mjs'; @@ -165,6 +166,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { const activityCancellationAcknowledgement = fixture.activity_cancellation_acknowledgement ? checkActivityCancellationAcknowledgement(fixture, observation, workflowId) : null; const serviceCatalog = fixture.service_catalog ? checkServiceCatalog(fixture, observation, workflowId) : null; + const workflowImport = fixture.workflow_import ? checkWorkflowImport(fixture, observation, workflowId) : null; const systemMetrics = fixture.system_metrics ? checkSystemMetrics(fixture, observation, workflowId) : null; const operatorDashboardTypes = fixture.operator_dashboard_types ? checkOperatorDashboardTypes(fixture, observation, workflowId) : null; const prometheusMetrics = fixture.prometheus_metrics ? checkPrometheusMetrics(fixture, observation, workflowId) : null; @@ -727,6 +729,7 @@ export function checkObservation(fixture, observation, workflowId, artifacts) { ...(workerSessionActivities ? {worker_session_activities: workerSessionActivities} : {}), ...(activityCancellationAcknowledgement ? {activity_cancellation_acknowledgement: activityCancellationAcknowledgement} : {}), ...(serviceCatalog ? {service_catalog: serviceCatalog} : {}), + ...(workflowImport ? {workflow_import: workflowImport} : {}), ...(systemMetrics ? {system_metrics: systemMetrics} : {}), ...(operatorDashboardTypes ? {operator_dashboard_types: operatorDashboardTypes} : {}), ...(prometheusMetrics ? {prometheus_metrics: prometheusMetrics} : {}), diff --git a/scripts/conformance/server-parity/probe.php b/scripts/conformance/server-parity/probe.php index c124890d2..4f293fc4a 100644 --- a/scripts/conformance/server-parity/probe.php +++ b/scripts/conformance/server-parity/probe.php @@ -438,6 +438,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, $activityCancellationAcknowledgement = isset($fixture['activity_cancellation_acknowledgement']) ? finishHttpActivityCancellationAcknowledgement($client, $fixture, $workflowId, $url) : null; $serviceCatalog = isset($fixture['service_catalog']) ? finishHttpServiceCatalog($client, $fixture, $workflowId, $url) : null; + $workflowImport = isset($fixture['workflow_import']) ? finishHttpWorkflowImport($client, $fixture, $workflowId, $url) : null; $systemMetrics = isset($fixture['system_metrics']) ? finishHttpSystemMetrics($client, $fixture, $workflowId, $url) : null; $operatorDashboardTypes = isset($fixture['operator_dashboard_types']) ? finishHttpOperatorDashboardTypes($client, $fixture, $workflowId, $url) : null; $prometheusMetrics = isset($fixture['prometheus_metrics']) ? finishHttpPrometheusMetrics($client, $fixture, $workflowId, $url) : null; @@ -553,6 +554,7 @@ function httpObservation(array $fixture, string $workflowId, string $namespace, 'worker_session_activities' => $workerSessionActivities, 'activity_cancellation_acknowledgement' => $activityCancellationAcknowledgement, 'service_catalog' => $serviceCatalog, + 'workflow_import' => $workflowImport, 'system_metrics' => $systemMetrics, 'operator_dashboard_types' => $operatorDashboardTypes, 'prometheus_metrics' => $prometheusMetrics, @@ -872,6 +874,7 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa require __DIR__.'/worker-session-activities-probe.php'; require __DIR__.'/activity-cancellation-ack-probe.php'; require __DIR__.'/service-catalog-probe.php'; + require __DIR__.'/workflow-import-probe.php'; require __DIR__.'/system-metrics-probe.php'; require __DIR__.'/operator-dashboard-types-probe.php'; require __DIR__.'/prometheus-metrics-probe.php'; @@ -1060,6 +1063,9 @@ function embeddedObservation(array $fixture, string $workflowId, string $namespa if ($mode === 'embedded' && isset($fixture['service_catalog'])) { $observation['service_catalog'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_service_catalog']; } + if ($mode === 'embedded' && isset($fixture['workflow_import'])) { + $observation['workflow_import'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_workflow_import']; + } if ($mode === 'embedded' && isset($fixture['system_metrics'])) { $observation['system_metrics'] = ['applicable' => false, 'reason' => 'embedded_has_no_http_metrics']; } diff --git a/scripts/conformance/server-parity/workflow-import-contract.mjs b/scripts/conformance/server-parity/workflow-import-contract.mjs new file mode 100644 index 000000000..02db4c5ac --- /dev/null +++ b/scripts/conformance/server-parity/workflow-import-contract.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict'; + +// Reviewed history import (EmbeddedV2ImportController), recorded against +// published Server 2.11.1. The fixture's `expected` holds the whole +// normalized observation, with the probe's workflow prefix as `{id}`, bundle +// identities, checksums and signatures as `{