From 17a2a00ed5248bfc1b5f4efee1267610b0610727 Mon Sep 17 00:00:00 2001 From: James Russo Date: Mon, 17 Aug 2026 14:24:31 -0700 Subject: [PATCH] feat(cloud): default distributed plans to v2 (#3311) * feat(cloud): default distributed plans to v2 * fix(cloud): address plan v2 review feedback * fix(examples): document explicit v2 samples --- docs/packages/aws-lambda.mdx | 16 +++-- docs/packages/gcp-cloud-run.mdx | 16 +++-- examples/aws-lambda/README.md | 32 ++++++--- .../aws-lambda/sample-events/assemble-v1.json | 12 ++++ .../aws-lambda/sample-events/assemble.json | 4 +- .../aws-lambda/sample-events/plan-v1.json | 15 +++++ examples/aws-lambda/sample-events/plan.json | 2 +- .../sample-events/render-chunk-v1.json | 9 +++ .../sample-events/render-chunk.json | 3 +- examples/aws-lambda/scripts/smoke.sh | 6 +- examples/aws-lambda/template.yaml | 7 +- examples/gcp-cloud-run/README.md | 30 ++++++--- .../sample-events/assemble-v1.json | 12 ++++ .../gcp-cloud-run/sample-events/assemble.json | 5 +- .../gcp-cloud-run/sample-events/plan-v1.json | 7 ++ .../gcp-cloud-run/sample-events/plan.json | 1 - .../sample-events/render-chunk-v1.json | 9 +++ .../sample-events/render-chunk.json | 4 +- examples/gcp-cloud-run/scripts/smoke.sh | 12 ++-- packages/aws-lambda/README.md | 26 +++++-- .../HyperframesRenderStack.snapshot.test.ts | 12 ++++ .../src/cdk/HyperframesRenderStack.ts | 7 +- packages/aws-lambda/src/events.ts | 20 +++--- packages/aws-lambda/src/handler.test.ts | 66 ++++++++++++++++-- packages/aws-lambda/src/handler.ts | 67 +++++++++++++++---- .../aws-lambda/src/sdk/renderToLambda.test.ts | 14 ++-- packages/aws-lambda/src/sdk/renderToLambda.ts | 6 +- packages/gcp-cloud-run/README.md | 21 ++++-- packages/gcp-cloud-run/src/events.ts | 20 +++--- .../src/sdk/renderToCloudRun.test.ts | 8 +-- .../gcp-cloud-run/src/sdk/renderToCloudRun.ts | 6 +- packages/gcp-cloud-run/src/server.test.ts | 21 ++++-- packages/gcp-cloud-run/src/server.ts | 36 +++++----- .../terraform/smoke-safety.test.ts | 4 +- .../gcp-cloud-run/terraform/workflow.test.ts | 4 +- .../gcp-cloud-run/terraform/workflow.yaml | 6 +- 36 files changed, 403 insertions(+), 143 deletions(-) create mode 100644 examples/aws-lambda/sample-events/assemble-v1.json create mode 100644 examples/aws-lambda/sample-events/plan-v1.json create mode 100644 examples/aws-lambda/sample-events/render-chunk-v1.json create mode 100644 examples/gcp-cloud-run/sample-events/assemble-v1.json create mode 100644 examples/gcp-cloud-run/sample-events/plan-v1.json create mode 100644 examples/gcp-cloud-run/sample-events/render-chunk-v1.json diff --git a/docs/packages/aws-lambda.mdx b/docs/packages/aws-lambda.mdx index 2d8d40d1f..199be356c 100644 --- a/docs/packages/aws-lambda.mdx +++ b/docs/packages/aws-lambda.mdx @@ -70,7 +70,6 @@ const site = await deploySite({ const handle = await renderToLambda({ siteHandle: site, - planProtocol: "v2", bucketName: site.bucketName, stateMachineArn: "arn:aws:states:us-east-1:123456789012:stateMachine:hyperframes-render", config: { @@ -88,10 +87,17 @@ const progress = await getRenderProgress({ executionArn: handle.executionArn }); console.log(progress.status, progress.overallProgress, progress.costs.displayCost); ``` -Plan v2 is recommended for new integrations because workers fetch -manifest-selected content-addressed artifacts. For backwards compatibility, -omitting `planProtocol` still selects v1; existing callers do not change -behavior until they opt in. +Plan v2 is the default because workers fetch manifest-selected +content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is +omitted. Deprecated v1 compatibility remains available by passing +`planProtocol: "v1"`. + + + For an existing installation, pause new renders and drain active Step + Functions executions. Redeploy the Lambda handler and SAM/CDK state machine + from the same package version before upgrading the application SDK. Older + infrastructure may default omission to v1 or lack v2 support. + `renderToLambda()` validates the distributed render config before starting the Step Functions execution, so invalid dimensions, formats, chunk sizes, or payload sizes fail synchronously. diff --git a/docs/packages/gcp-cloud-run.mdx b/docs/packages/gcp-cloud-run.mdx index f7beacfe3..edf5317db 100644 --- a/docs/packages/gcp-cloud-run.mdx +++ b/docs/packages/gcp-cloud-run.mdx @@ -79,7 +79,6 @@ import { getRenderProgress, renderToCloudRun } from "@hyperframes/gcp-cloud-run/ const handle = await renderToCloudRun({ projectDir: "./my-composition", - planProtocol: "v2", config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, bucketName: "hyperframes-render-my-project", projectId: "my-project", @@ -97,10 +96,17 @@ while (progress.status === "running") { console.log(progress.status, progress.outputFile, progress.costs.displayCost); ``` -Plan v2 is recommended for new integrations because workers fetch -manifest-selected content-addressed artifacts. For backwards compatibility, -omitting `planProtocol` still selects v1; existing callers do not change -behavior until they opt in. +Plan v2 is the default because workers fetch manifest-selected +content-addressed artifacts. The SDK sends explicit v2 when `planProtocol` is +omitted. Deprecated v1 compatibility remains available by passing +`planProtocol: "v1"`. + + + For an existing installation, pause new renders and drain active workflow + executions. Redeploy the Cloud Run image and Cloud Workflows definition from + the same package version before upgrading the application SDK. Older + workflows may default omission to v1 or lack v2 support. + Pass `projectDir` for one-shot uploads, or call `deploySite()` separately and reuse the returned site handle across many renders. diff --git a/examples/aws-lambda/README.md b/examples/aws-lambda/README.md index 3a04edb25..6c429820d 100644 --- a/examples/aws-lambda/README.md +++ b/examples/aws-lambda/README.md @@ -75,7 +75,6 @@ aws stepfunctions start-execution \ "ProjectS3Uri": "s3://${RENDER_BUCKET}/projects/my-project.tar.gz", "PlanOutputS3Prefix": "s3://${RENDER_BUCKET}/renders/$(date +%s)/", "OutputS3Uri": "s3://${RENDER_BUCKET}/output.mp4", - "PlanProtocol": "v2", "Config": { "fps": 30, "width": 1920, @@ -92,10 +91,20 @@ EOF The Step Functions execution kicks off Plan, fans out RenderChunk via the Map state, and finally Assemble. Final mp4 lands at `OutputS3Uri`. -Plan v2 is recommended for new integrations. `PlanProtocol` may be `"v1"` or -`"v2"`; absent still defaults to v1 for backwards compatibility. V2 uses -separate manifest and content-addressed artifact locators throughout the -workflow and never places a v2 object in `PlanS3Uri`. +Plan v2 is the default when `PlanProtocol` is absent. V2 uses separate +manifest and content-addressed artifact locators throughout the workflow and +never places a v2 object in `PlanS3Uri`. The deprecated v1 transport remains +available by sending `"PlanProtocol": "v1"` explicitly. + +### Upgrading an existing stack + +Pause new renders and let active Step Functions executions drain before the +upgrade. Redeploy the Lambda handler and this state machine (or the matching +CDK construct) from the same package version before upgrading the application +that calls `renderToLambda`. The new SDK sends explicit v2 by default, while +older infrastructure may default omission to v1 or lack v2 support. Keep +passing `planProtocol: "v1"` until the infrastructure redeploy completes if +you need a staged migration. ## Local invocation @@ -112,10 +121,13 @@ sam validate sam local invoke RenderFunction --event sample-events/plan.json ``` -The `sample-events/` directory ships small JSON payloads for each of the -three actions. They reference fake S3 URIs — useful for sanity-checking -the handler's dispatch logic; not for full end-to-end testing (real S3 -calls require credentials and a project zip to actually exist). +The `sample-events/` directory ships three tiers for each action: +`*.json` demonstrates default v2 with `PlanProtocol` omitted, `*-v1.json` +demonstrates deprecated explicit-v1 compatibility, and `*-v2.json` +demonstrates callers that stamp v2 explicitly. They reference fake S3 URIs — +useful for sanity-checking the handler's dispatch logic; not for full +end-to-end testing (real S3 calls require credentials and a project zip to +actually exist). ## End-to-end smoke + benchmark @@ -124,7 +136,7 @@ the architecture works on a deployed Lambda — use the local smoke script: ```bash -# Defaults use the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr). +# Defaults use Plan v2 and the fixture's meta.json minPsnr (30 dB for mp4-h264-sdr). ./scripts/smoke.sh # Customised: diff --git a/examples/aws-lambda/sample-events/assemble-v1.json b/examples/aws-lambda/sample-events/assemble-v1.json new file mode 100644 index 000000000..6dcc2aa8c --- /dev/null +++ b/examples/aws-lambda/sample-events/assemble-v1.json @@ -0,0 +1,12 @@ +{ + "Action": "assemble", + "PlanProtocol": "v1", + "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz", + "ChunkS3Uris": [ + "s3://example-bucket/renders/sample/chunks/0000.mp4", + "s3://example-bucket/renders/sample/chunks/0001.mp4" + ], + "AudioS3Uri": null, + "OutputS3Uri": "s3://example-bucket/renders/sample/output.mp4", + "Format": "mp4" +} diff --git a/examples/aws-lambda/sample-events/assemble.json b/examples/aws-lambda/sample-events/assemble.json index 7e3ab4b9f..22d43c884 100644 --- a/examples/aws-lambda/sample-events/assemble.json +++ b/examples/aws-lambda/sample-events/assemble.json @@ -1,6 +1,8 @@ { "Action": "assemble", - "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz", + "PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json", + "PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256", + "PlanHash": "0000000000000000000000000000000000000000000000000000000000000000", "ChunkS3Uris": [ "s3://example-bucket/renders/sample/chunks/0000.mp4", "s3://example-bucket/renders/sample/chunks/0001.mp4", diff --git a/examples/aws-lambda/sample-events/plan-v1.json b/examples/aws-lambda/sample-events/plan-v1.json new file mode 100644 index 000000000..28e174110 --- /dev/null +++ b/examples/aws-lambda/sample-events/plan-v1.json @@ -0,0 +1,15 @@ +{ + "Action": "plan", + "PlanProtocol": "v1", + "ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz", + "PlanOutputS3Prefix": "s3://example-bucket/renders/sample/", + "Config": { + "fps": 30, + "width": 1920, + "height": 1080, + "format": "mp4", + "chunkSize": 240, + "maxParallelChunks": 8, + "runtimeCap": "lambda" + } +} diff --git a/examples/aws-lambda/sample-events/plan.json b/examples/aws-lambda/sample-events/plan.json index de6f7c12f..51bcf48ea 100644 --- a/examples/aws-lambda/sample-events/plan.json +++ b/examples/aws-lambda/sample-events/plan.json @@ -1,6 +1,6 @@ { "Action": "plan", - "ProjectS3Uri": "s3://example-bucket/projects/sample-composition.tar.gz", + "ProjectS3Uri": "s3://example-bucket/projects/sample.tar.gz", "PlanOutputS3Prefix": "s3://example-bucket/renders/sample/", "Config": { "fps": 30, diff --git a/examples/aws-lambda/sample-events/render-chunk-v1.json b/examples/aws-lambda/sample-events/render-chunk-v1.json new file mode 100644 index 000000000..bc59ed151 --- /dev/null +++ b/examples/aws-lambda/sample-events/render-chunk-v1.json @@ -0,0 +1,9 @@ +{ + "Action": "renderChunk", + "PlanProtocol": "v1", + "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz", + "PlanHash": "0000000000000000000000000000000000000000000000000000000000000000", + "ChunkIndex": 0, + "ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/", + "Format": "mp4" +} diff --git a/examples/aws-lambda/sample-events/render-chunk.json b/examples/aws-lambda/sample-events/render-chunk.json index 46d191ae2..51959bd8e 100644 --- a/examples/aws-lambda/sample-events/render-chunk.json +++ b/examples/aws-lambda/sample-events/render-chunk.json @@ -1,6 +1,7 @@ { "Action": "renderChunk", - "PlanS3Uri": "s3://example-bucket/renders/sample/plan.tar.gz", + "PlanV2ManifestS3Uri": "s3://example-bucket/renders/sample/v2/manifest.json", + "PlanV2ArtifactS3Prefix": "s3://example-bucket/renders/sample/v2/artifacts/sha256", "PlanHash": "0000000000000000000000000000000000000000000000000000000000000000", "ChunkIndex": 0, "ChunkOutputS3Prefix": "s3://example-bucket/renders/sample/", diff --git a/examples/aws-lambda/scripts/smoke.sh b/examples/aws-lambda/scripts/smoke.sh index d90d46f71..f2f2ff6c9 100755 --- a/examples/aws-lambda/scripts/smoke.sh +++ b/examples/aws-lambda/scripts/smoke.sh @@ -31,7 +31,7 @@ # --region (default: $AWS_REGION or us-east-1) # --profile (default: $AWS_PROFILE, otherwise the AWS # default profile resolution chain) -# --plan-protocol (default: v1) +# --plan-protocol (default: v2) # --keep-stack (skip `sam delete` at the end) # --skip-build (skip the ZIP rebuild; use the existing one) # @@ -82,7 +82,7 @@ SMOKE_RUN_ID="${HYPERFRAMES_SMOKE_RUN_ID:-$(hf_new_smoke_run_id)}" STACK_NAME="${STACK_NAME:-hyperframes-lambda-smoke-${SMOKE_RUN_ID}}" AWS_REGION="${AWS_REGION:-us-east-1}" AWS_PROFILE="${AWS_PROFILE:-}" -PLAN_PROTOCOL="${PLAN_PROTOCOL:-v1}" +PLAN_PROTOCOL="${PLAN_PROTOCOL:-v2}" KEEP_STACK="false" SKIP_BUILD="false" REQUIRE_ENCODED_SHA_EQUAL="${REQUIRE_ENCODED_SHA_EQUAL:-false}" @@ -110,7 +110,7 @@ Flags: --stack-name SAM stack name (default: hyperframes-lambda-smoke-) --region AWS region (default: $AWS_REGION or us-east-1) --profile AWS profile (default: $AWS_PROFILE) - --plan-protocol plan transport(s) to compare (default: v1) + --plan-protocol plan transport(s) to compare (default: v2) --reserved-concurrency Lambda Map MaxConcurrency cap (default: 16) --keep-stack skip `sam delete` at the end (manual teardown later) --require-encoded-sha-equal also gate byte-identical encoded MP4 output diff --git a/examples/aws-lambda/template.yaml b/examples/aws-lambda/template.yaml index 7ae0b7f6f..8e47da3eb 100644 --- a/examples/aws-lambda/template.yaml +++ b/examples/aws-lambda/template.yaml @@ -222,12 +222,12 @@ Resources: - Variable: $.PlanProtocol IsPresent: true Next: UnsupportedPlanProtocol - Default: Plan + Default: PlanV2 UnsupportedPlanProtocol: Type: Fail Error: PLAN_PROTOCOL_UNSUPPORTED - Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v1). + Cause: PlanProtocol must be "v1", "v2", or absent (defaults to v2). Plan: Type: Task @@ -236,6 +236,7 @@ Resources: FunctionName: !GetAtt RenderFunction.Arn Payload: Action: plan + PlanProtocol: v1 ProjectS3Uri.$: "$.ProjectS3Uri" PlanOutputS3Prefix.$: "$.PlanOutputS3Prefix" Config.$: "$.Config" @@ -391,6 +392,7 @@ Resources: FunctionName: !GetAtt RenderFunction.Arn Payload: Action: renderChunk + PlanProtocol: v1 ChunkIndex.$: "$.ChunkIndex" PlanS3Uri.$: "$.PlanS3Uri" PlanHash.$: "$.PlanHash" @@ -427,6 +429,7 @@ Resources: FunctionName: !GetAtt RenderFunction.Arn Payload: Action: assemble + PlanProtocol: v1 PlanS3Uri.$: "$.Plan.PlanS3Uri" ChunkS3Uris.$: "$.Chunks[*].ChunkS3Uri" AudioS3Uri.$: "$.Plan.AudioS3Uri" diff --git a/examples/gcp-cloud-run/README.md b/examples/gcp-cloud-run/README.md index 62732f992..0f92ca826 100644 --- a/examples/gcp-cloud-run/README.md +++ b/examples/gcp-cloud-run/README.md @@ -8,7 +8,7 @@ Cloud Workflows adapter for HyperFrames distributed rendering. ```text scripts/smoke.sh Owner-isolated real-GCP deploy, render, parity, cleanup -sample-events/ v1 and v2 handler request examples +sample-events/ Default-v2, explicit-v1, and explicit-v2 request examples ``` The Terraform module and Cloud Workflows definition live in @@ -16,8 +16,9 @@ The Terraform module and Cloud Workflows definition live in ## Protocol rollout -The workflow defaults to plan protocol v1 when `PlanProtocol` is absent. V2 is -accepted only when the caller explicitly sends `PlanProtocol: "v2"`. +The workflow defaults to Plan v2 when `PlanProtocol` is absent. Deprecated v1 +compatibility remains available only when the caller explicitly sends +`PlanProtocol: "v1"`. V1 and v2 use disjoint plan locators: @@ -26,9 +27,15 @@ V1 and v2 use disjoint plan locators: The workflow validates that the plan response matches the selected protocol before starting chunk fan-out. It never silently falls back from v2 to v1. -Deploy the v2 workflow only with a Cloud Run image whose handler implements -the matching v2 request/response contract. An older v1-only handler will keep -serving default v1 requests, but explicit v2 smoke executions will fail closed. +Deploy the workflow only with a Cloud Run image whose handler implements the +matching v2 request/response contract. + +For an existing installation, pause new renders and drain active workflow +executions. Redeploy the Cloud Run image and workflow from the same package +version before upgrading the application SDK. The new SDK sends explicit v2; +older workflows may still default omission to v1 or lack v2 support. Keep +passing `planProtocol: "v1"` until the infrastructure redeploy completes if +you need a staged migration. ## Prerequisites @@ -40,7 +47,7 @@ serving default v1 requests, but explicit v2 smoke executions will fail closed. ## Run the smoke -V1 remains the safe default: +Plan v2 is the normal smoke path: ```bash ./scripts/smoke.sh \ @@ -120,12 +127,17 @@ old invocation's state or image implicitly. The sample events mirror the request bodies sent by Cloud Workflows: ```bash -# V1 +# Default v2 (PlanProtocol omitted) curl -sX POST localhost:8080/ \ -H 'content-type: application/json' \ --data @sample-events/plan.json | jq . -# Explicit v2 +# Deprecated explicit v1 compatibility +curl -sX POST localhost:8080/ \ + -H 'content-type: application/json' \ + --data @sample-events/plan-v1.json | jq . + +# Explicit v2 for callers that always stamp the protocol curl -sX POST localhost:8080/ \ -H 'content-type: application/json' \ --data @sample-events/plan-v2.json | jq . diff --git a/examples/gcp-cloud-run/sample-events/assemble-v1.json b/examples/gcp-cloud-run/sample-events/assemble-v1.json new file mode 100644 index 000000000..4be98aa21 --- /dev/null +++ b/examples/gcp-cloud-run/sample-events/assemble-v1.json @@ -0,0 +1,12 @@ +{ + "Action": "assemble", + "PlanProtocol": "v1", + "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", + "ChunkGcsUris": [ + "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4", + "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4" + ], + "AudioGcsUri": null, + "OutputGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/output.mp4", + "Format": "mp4" +} diff --git a/examples/gcp-cloud-run/sample-events/assemble.json b/examples/gcp-cloud-run/sample-events/assemble.json index 4be98aa21..1b4ebf282 100644 --- a/examples/gcp-cloud-run/sample-events/assemble.json +++ b/examples/gcp-cloud-run/sample-events/assemble.json @@ -1,7 +1,8 @@ { "Action": "assemble", - "PlanProtocol": "v1", - "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", + "PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json", + "PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256", + "PlanHash": "REPLACE_WITH_PLAN_HASH", "ChunkGcsUris": [ "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0000.mp4", "gs://hyperframes-render-PROJECT/renders/hf-render-demo/chunks/0001.mp4" diff --git a/examples/gcp-cloud-run/sample-events/plan-v1.json b/examples/gcp-cloud-run/sample-events/plan-v1.json new file mode 100644 index 000000000..6f0856668 --- /dev/null +++ b/examples/gcp-cloud-run/sample-events/plan-v1.json @@ -0,0 +1,7 @@ +{ + "Action": "plan", + "PlanProtocol": "v1", + "ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz", + "PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", + "Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" } +} diff --git a/examples/gcp-cloud-run/sample-events/plan.json b/examples/gcp-cloud-run/sample-events/plan.json index 6f0856668..667988b41 100644 --- a/examples/gcp-cloud-run/sample-events/plan.json +++ b/examples/gcp-cloud-run/sample-events/plan.json @@ -1,6 +1,5 @@ { "Action": "plan", - "PlanProtocol": "v1", "ProjectGcsUri": "gs://hyperframes-render-PROJECT/sites/abc123/project.tar.gz", "PlanOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", "Config": { "fps": 30, "width": 1920, "height": 1080, "format": "mp4" } diff --git a/examples/gcp-cloud-run/sample-events/render-chunk-v1.json b/examples/gcp-cloud-run/sample-events/render-chunk-v1.json new file mode 100644 index 000000000..c11bd441a --- /dev/null +++ b/examples/gcp-cloud-run/sample-events/render-chunk-v1.json @@ -0,0 +1,9 @@ +{ + "Action": "renderChunk", + "PlanProtocol": "v1", + "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", + "PlanHash": "REPLACE_WITH_PLAN_HASH", + "ChunkIndex": 0, + "ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", + "Format": "mp4" +} diff --git a/examples/gcp-cloud-run/sample-events/render-chunk.json b/examples/gcp-cloud-run/sample-events/render-chunk.json index c11bd441a..cbc3c8be0 100644 --- a/examples/gcp-cloud-run/sample-events/render-chunk.json +++ b/examples/gcp-cloud-run/sample-events/render-chunk.json @@ -1,7 +1,7 @@ { "Action": "renderChunk", - "PlanProtocol": "v1", - "PlanGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/plan.tar.gz", + "PlanV2ManifestGcsUri": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/manifest.json", + "PlanV2ArtifactGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/v2/artifacts/sha256", "PlanHash": "REPLACE_WITH_PLAN_HASH", "ChunkIndex": 0, "ChunkOutputGcsPrefix": "gs://hyperframes-render-PROJECT/renders/hf-render-demo/", diff --git a/examples/gcp-cloud-run/scripts/smoke.sh b/examples/gcp-cloud-run/scripts/smoke.sh index 91a4d4d58..669b82621 100755 --- a/examples/gcp-cloud-run/scripts/smoke.sh +++ b/examples/gcp-cloud-run/scripts/smoke.sh @@ -2,11 +2,11 @@ # Owner-isolated real-GCP smoke + v1/v2 parity test for the HyperFrames # Cloud Run adapter. # -# The default is intentionally v1-only. Plan protocol v2 must be opted into -# explicitly with --protocols v1,v2. Every invocation derives a unique, -# length-safe resource prefix and uses an isolated Terraform working directory -# and state file. Cleanup verifies every owned resource is absent and fails -# closed on API/authentication errors. +# The default exercises Plan v2. Deprecated v1 compatibility can be selected +# explicitly with --protocols v1 or compared with --protocols v1,v2. Every +# invocation derives a unique, length-safe resource prefix and uses an isolated +# Terraform working directory and state file. Cleanup verifies every owned +# resource is absent and fails closed on API/authentication errors. # # Usage: # ./smoke.sh --project @@ -33,7 +33,7 @@ REGION="${GCP_REGION:-us-central1}" FIXTURE="${FIXTURE:-mp4-h264-sdr}" CHUNK_SIZES="${CHUNK_SIZES:-}" PSNR_THRESHOLD="${PSNR_THRESHOLD:-35}" -PROTOCOLS="${PROTOCOLS:-v1}" +PROTOCOLS="${PROTOCOLS:-v2}" OWNER="${HYPERFRAMES_SMOKE_OWNER:-}" AR_REPO="${AR_REPO:-}" AR_REPO_WAS_EXPLICIT=0 diff --git a/packages/aws-lambda/README.md b/packages/aws-lambda/README.md index 14423f08e..0ef9a34cb 100644 --- a/packages/aws-lambda/README.md +++ b/packages/aws-lambda/README.md @@ -47,14 +47,13 @@ inside Step Functions' history budget (under 200 bytes per chunk). ### Plan transport selection -Plan v2 is recommended for new integrations. `renderToLambda` still defaults -an omitted `planProtocol` to the existing monolithic v1 transport for -backwards compatibility, so select v2 explicitly: +Plan v2 is the default for new renders. When `planProtocol` is omitted, +`renderToLambda` sends an explicit `PlanProtocol: "v2"` so the SDK and the +deployed state machine agree: ```ts await renderToLambda({ // ...bucket, state machine, project, and config... - planProtocol: "v2", }); ``` @@ -64,7 +63,24 @@ only manifest-selected chunk artifacts, while the assembler fetches its own metadata and audio subset. Blobs are immutable SHA-256-addressed objects, verified on upload and download, and the manifest is published last. Unknown protocols and digest mismatches are terminal Step Functions -errors. Omit the selector—or use `"v1"`—to retain the prior wire contract. +errors. The monolithic v1 transport remains available as deprecated +compatibility by passing `planProtocol: "v1"` explicitly. + +#### Upgrade order + +This default changes application behavior and requires a coordinated +infrastructure upgrade. Before upgrading an application that calls +`renderToLambda`: + +1. Pause new renders and let existing Step Functions executions drain. +2. Redeploy the Lambda handler and SAM template or CDK construct from the + same new package version. +3. Resume renders, then upgrade the application/SDK dependency. + +Older state machines can default missing protocol fields to v1 or lack v2 +branches, while the new SDK sends explicit v2. If infrastructure cannot be +redeployed first, keep the application on its previous package version or +pass `planProtocol: "v1"` explicitly until the redeploy is complete. ## Chrome runtime diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts index c7f27f503..010a0d2c3 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.snapshot.test.ts @@ -151,6 +151,18 @@ describe("HyperframesRenderStack — snapshot", () => { expect(actualStates.sort()).toEqual([...EXPECTED_STATE_NAMES].sort()); }); + it("defaults omitted plan protocol to v2 and preserves the explicit v1 branch", () => { + for (const definition of [SYNTHED.definition, readSamDefinition()]) { + const selection = requireRecord( + definition.States.SelectPlanProtocol, + "SelectPlanProtocol state", + ); + expect(selection.Default).toBe("PlanV2"); + expect(JSON.stringify(selection)).toContain('"StringEquals":"v1"'); + expect(JSON.stringify(definition.States.Plan)).toContain('"PlanProtocol":"v1"'); + } + }); + it("preserves every typed non-retryable error name across the three Lambda tasks", () => { const { definition } = SYNTHED; const collected = new Set(); diff --git a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts index baa7bd6b1..8b0c4a7a1 100644 --- a/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts +++ b/packages/aws-lambda/src/cdk/HyperframesRenderStack.ts @@ -247,6 +247,7 @@ export class HyperframesRenderStack extends Construct { lambdaFunction: this.renderFunction, payload: sfn.TaskInput.fromObject({ Action: "plan", + PlanProtocol: "v1", "ProjectS3Uri.$": "$.ProjectS3Uri", "PlanOutputS3Prefix.$": "$.PlanOutputS3Prefix", "Config.$": "$.Config", @@ -319,6 +320,7 @@ export class HyperframesRenderStack extends Construct { lambdaFunction: this.renderFunction, payload: sfn.TaskInput.fromObject({ Action: "renderChunk", + PlanProtocol: "v1", "ChunkIndex.$": "$.ChunkIndex", "PlanS3Uri.$": "$.PlanS3Uri", "PlanHash.$": "$.PlanHash", @@ -361,6 +363,7 @@ export class HyperframesRenderStack extends Construct { lambdaFunction: this.renderFunction, payload: sfn.TaskInput.fromObject({ Action: "assemble", + PlanProtocol: "v1", "PlanS3Uri.$": "$.Plan.PlanS3Uri", "ChunkS3Uris.$": "$.Chunks[*].ChunkS3Uri", "AudioS3Uri.$": "$.Plan.AudioS3Uri", @@ -473,13 +476,13 @@ export class HyperframesRenderStack extends Construct { const unsupportedPlanProtocol = new sfn.Fail(this, "UnsupportedPlanProtocol", { error: "PLAN_PROTOCOL_UNSUPPORTED", - cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v1).', + cause: 'PlanProtocol must be "v1", "v2", or absent (defaults to v2).', }); return new sfn.Choice(this, "SelectPlanProtocol") .when(sfn.Condition.stringEquals("$.PlanProtocol", "v2"), planV2) .when(sfn.Condition.stringEquals("$.PlanProtocol", "v1"), plan) .when(sfn.Condition.isPresent("$.PlanProtocol"), unsupportedPlanProtocol) - .otherwise(plan); + .otherwise(planV2); } } diff --git a/packages/aws-lambda/src/events.ts b/packages/aws-lambda/src/events.ts index 44b5e6066..8b25122dc 100644 --- a/packages/aws-lambda/src/events.ts +++ b/packages/aws-lambda/src/events.ts @@ -55,17 +55,17 @@ interface PlanEventBase { } /** - * Legacy/default plan transport. Absence is deliberately interpreted as v1. + * Legacy plan transport. Callers must select it explicitly. * * @deprecated Use {@link PlanV2Event} for new integrations. */ export interface PlanV1Event extends PlanEventBase { - PlanProtocol?: "v1"; + PlanProtocol: "v1"; } -/** Explicit opt-in to the content-addressed v2 plan transport. */ +/** Default content-addressed v2 plan transport. */ export interface PlanV2Event extends PlanEventBase { - PlanProtocol: "v2"; + PlanProtocol?: "v2"; } export type PlanEvent = PlanV1Event | PlanV2Event; @@ -90,12 +90,12 @@ interface RenderChunkEventBase { } /** - * Legacy/default chunk event. + * Legacy chunk event. Callers must select it explicitly. * * @deprecated Use {@link RenderChunkV2Event} for new integrations. */ export interface RenderChunkV1Event extends RenderChunkEventBase { - PlanProtocol?: "v1"; + PlanProtocol: "v1"; /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */ PlanS3Uri: string; } @@ -105,7 +105,7 @@ export interface RenderChunkV1Event extends RenderChunkEventBase { * describes the exact content-addressed artifacts needed by this chunk. */ export interface RenderChunkV2Event extends RenderChunkEventBase { - PlanProtocol: "v2"; + PlanProtocol?: "v2"; PlanV2ManifestS3Uri: string; PlanV2ArtifactS3Prefix: string; } @@ -136,19 +136,19 @@ interface AssembleEventBase { } /** - * Legacy/default assemble event. + * Legacy assemble event. Callers must select it explicitly. * * @deprecated Use {@link AssembleV2Event} for new integrations. */ export interface AssembleV1Event extends AssembleEventBase { - PlanProtocol?: "v1"; + PlanProtocol: "v1"; /** S3 URI of the v1 plan tar produced by a PlanEvent invocation. */ PlanS3Uri: string; } /** V2 assemble event, scoped to manifest-declared assembler artifacts. */ export interface AssembleV2Event extends AssembleEventBase { - PlanProtocol: "v2"; + PlanProtocol?: "v2"; PlanV2ManifestS3Uri: string; PlanV2ArtifactS3Prefix: string; PlanHash: string; diff --git a/packages/aws-lambda/src/handler.test.ts b/packages/aws-lambda/src/handler.test.ts index d1af1a96d..ec8a3d540 100644 --- a/packages/aws-lambda/src/handler.test.ts +++ b/packages/aws-lambda/src/handler.test.ts @@ -145,6 +145,7 @@ describe("unwrapEvent", () => { it("unwraps a Step Functions { Payload } envelope", () => { const inner: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanS3Uri: "s3://bucket/plan.tar.gz", PlanHash: "deadbeef", ChunkIndex: 3, @@ -158,6 +159,7 @@ describe("unwrapEvent", () => { it("unwraps multiple levels of envelopes", () => { const inner: AssembleEvent = { Action: "assemble", + PlanProtocol: "v1", PlanS3Uri: "s3://bucket/plan.tar.gz", ChunkS3Uris: ["s3://bucket/chunks/0001.mp4"], AudioS3Uri: null, @@ -176,7 +178,7 @@ describe("unwrapEvent", () => { }); describe("handler dispatch", () => { - it("routes Action='plan' to the plan primitive", async () => { + it("preserves explicit v1 plan compatibility", async () => { const tmpRoot = makeTmpRoot(); const s3 = new FakeS3Client(); // Seed a fake project tarball so the untar step has something to chew on. @@ -213,6 +215,7 @@ describe("handler dispatch", () => { const event: PlanEvent = { Action: "plan", + PlanProtocol: "v1", ProjectS3Uri: "s3://bucket/project.tar.gz", PlanOutputS3Prefix: "s3://bucket/renders/abc/", Config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, @@ -270,6 +273,7 @@ describe("handler dispatch", () => { handler( { Action: "plan", + PlanProtocol: "v1", ProjectS3Uri: "s3://bucket/project.tar.gz", PlanOutputS3Prefix: "s3://bucket/renders/terminal/", Config: { fps: 30, width: 640, height: 360, format: "mp4" }, @@ -332,6 +336,7 @@ describe("handler dispatch", () => { const event: PlanEvent = { Action: "plan", + PlanProtocol: "v1", ProjectS3Uri: "s3://bucket/project.tar.gz", PlanOutputS3Prefix: "s3://bucket/renders/abc/", Config: { fps: 30, width: 1920, height: 1080, format: "mp4" }, @@ -406,6 +411,7 @@ describe("handler dispatch", () => { const event: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanS3Uri: "s3://bucket/plan.tar.gz", PlanHash: "fakehash", ChunkIndex: 2, @@ -455,6 +461,7 @@ describe("handler dispatch", () => { const event: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanS3Uri: "s3://bucket/plan.tar.gz", PlanHash: "not-the-real-hash", ChunkIndex: 0, @@ -511,6 +518,7 @@ describe("handler dispatch", () => { const event: AssembleEvent = { Action: "assemble", + PlanProtocol: "v1", PlanS3Uri: "s3://bucket/plan.tar.gz", ChunkS3Uris: ["s3://bucket/chunks/0001.mp4", "s3://bucket/chunks/0002.mp4"], AudioS3Uri: null, @@ -541,7 +549,7 @@ describe("handler dispatch", () => { expect(assembleMock).toHaveBeenCalledTimes(1); }); - it("runs v2 plan → target-scoped chunk → assemble without a PlanS3Uri", async () => { + it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => { const tmpRoot = makeTmpRoot(); const s3 = new FakeS3Client(); s3.objects.set("s3://bucket/project.tar.gz", await makeMinimalProjectTar()); @@ -614,7 +622,6 @@ describe("handler dispatch", () => { const planned = await handler( { Action: "plan", - PlanProtocol: "v2", ProjectS3Uri: "s3://bucket/project.tar.gz", PlanOutputS3Prefix: "s3://bucket/renders/v2/", Config: { fps: 30, width: 640, height: 360, format: "mp4" }, @@ -642,7 +649,6 @@ describe("handler dispatch", () => { const chunk = await handler( { Action: "renderChunk", - PlanProtocol: "v2", PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri, PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix, PlanHash: planned.PlanHash, @@ -661,7 +667,6 @@ describe("handler dispatch", () => { await handler( { Action: "assemble", - PlanProtocol: "v2", PlanV2ManifestS3Uri: planned.PlanV2ManifestS3Uri, PlanV2ArtifactS3Prefix: planned.PlanV2ArtifactS3Prefix, PlanHash: planned.PlanHash, @@ -677,6 +682,56 @@ describe("handler dispatch", () => { ).toBe(true); }); + it("rejects unknown plan protocol values", async () => { + const tmpRoot = makeTmpRoot(); + const s3 = new FakeS3Client(); + + await expect( + handler( + { + Action: "plan", + PlanProtocol: "v3", + ProjectS3Uri: "s3://bucket/project.tar.gz", + PlanOutputS3Prefix: "s3://bucket/renders/invalid/", + Config: { fps: 30, width: 640, height: 360, format: "mp4" }, + } as unknown as LambdaEvent, + { + s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client, + tmpRoot, + skipChromeResolution: true, + }, + ), + ).rejects.toMatchObject({ name: "PLAN_PROTOCOL_UNSUPPORTED" }); + expect(s3.ops).toHaveLength(0); + }); + + it("rejects mixed v1/v2 plan locators at runtime", async () => { + const tmpRoot = makeTmpRoot(); + const s3 = new FakeS3Client(); + + await expect( + handler( + { + Action: "renderChunk", + PlanS3Uri: "s3://bucket/plan.tar.gz", + PlanHash: "fakehash", + ChunkIndex: 0, + ChunkOutputS3Prefix: "s3://bucket/renders/mixed/", + Format: "mp4", + } as unknown as LambdaEvent, + { + s3: s3 as unknown as import("@aws-sdk/client-s3").S3Client, + tmpRoot, + skipChromeResolution: true, + }, + ), + ).rejects.toMatchObject({ + name: "PLAN_PROTOCOL_UNSUPPORTED", + message: expect.stringContaining("mixed or missing plan locators"), + }); + expect(s3.ops).toHaveLength(0); + }); + it("rejects unknown actions", async () => { const tmpRoot = makeTmpRoot(); await expect( @@ -735,6 +790,7 @@ describe("handler — S3 URI allowlist (security: F-004)", () => { const event: AssembleEvent = { Action: "assemble", + PlanProtocol: "v1", PlanS3Uri: "s3://good-bucket/plan.tar.gz", ChunkS3Uris: ["s3://good-bucket/chunks/0001.mp4", "s3://evil-bucket/chunks/0002.mp4"], AudioS3Uri: null, diff --git a/packages/aws-lambda/src/handler.ts b/packages/aws-lambda/src/handler.ts index e16b68b78..08fc2caf3 100644 --- a/packages/aws-lambda/src/handler.ts +++ b/packages/aws-lambda/src/handler.ts @@ -44,9 +44,12 @@ import type { LambdaEvent, LambdaResult, PlanEvent, + PlanV2Event, PlanLambdaResult, RenderChunkEvent, + RenderChunkV2Event, RenderChunkLambdaResult, + AssembleV2Event, } from "./events.js"; import { downloadS3ObjectToFile, @@ -97,6 +100,7 @@ export interface HandlerDeps { */ export async function handler(event: LambdaEvent, deps?: HandlerDeps): Promise { const unwrapped = unwrapEvent(event); + validatePlanProtocolShape(unwrapped); validateEventS3Uris(unwrapped); primeRuntimeEnv(); // Single structured boot log line — CloudWatch Logs Insights queries @@ -201,6 +205,43 @@ function isLambdaAction(value: string): value is LambdaAction { return value === "plan" || value === "renderChunk" || value === "assemble"; } +// This is the single fail-closed boundary for the wire union. Keeping all +// forbidden locator combinations together makes mixed-protocol input auditable. +// fallow-ignore-next-line complexity +function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | AssembleEvent): void { + const raw = event as unknown as Record; + const protocol = raw.PlanProtocol; + if (protocol !== undefined && protocol !== "v1" && protocol !== "v2") { + const error = new Error( + `[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v1", "v2", or absent`, + ); + error.name = "PLAN_PROTOCOL_UNSUPPORTED"; + throw error; + } + if (event.Action === "plan") return; + + const effectiveProtocol = protocol ?? "v2"; + const hasV1Locator = typeof raw.PlanS3Uri === "string"; + const hasV2Manifest = typeof raw.PlanV2ManifestS3Uri === "string"; + const hasV2Prefix = typeof raw.PlanV2ArtifactS3Prefix === "string"; + const valid = + effectiveProtocol === "v2" + ? !hasV1Locator && hasV2Manifest && hasV2Prefix + : hasV1Locator && !hasV2Manifest && !hasV2Prefix; + if (!valid) { + const error = new Error( + `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`, + ); + error.name = "PLAN_PROTOCOL_UNSUPPORTED"; + throw error; + } + if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioS3Uri !== null) { + const error = new Error("[handler] v2 assemble audio must be materialized from the manifest"); + error.name = "PLAN_PROTOCOL_UNSUPPORTED"; + throw error; + } +} + /** * Emit a single JSON line to stdout. CloudWatch ingests each line as a * structured event; Logs Insights queries can `filter event="..."` and @@ -229,14 +270,14 @@ function summarizeEvent( return { projectS3Uri: event.ProjectS3Uri, planOutputS3Prefix: event.PlanOutputS3Prefix, - planProtocol: event.PlanProtocol ?? "v1", + planProtocol: event.PlanProtocol ?? "v2", format: event.Config.format, fps: event.Config.fps, }; case "renderChunk": return { - planProtocol: event.PlanProtocol ?? "v1", - ...(event.PlanProtocol === "v2" + planProtocol: event.PlanProtocol ?? "v2", + ...(event.PlanProtocol !== "v1" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri }), chunkIndex: event.ChunkIndex, @@ -244,8 +285,8 @@ function summarizeEvent( }; case "assemble": return { - planProtocol: event.PlanProtocol ?? "v1", - ...(event.PlanProtocol === "v2" + planProtocol: event.PlanProtocol ?? "v2", + ...(event.PlanProtocol !== "v1" ? { planV2ManifestS3Uri: event.PlanV2ManifestS3Uri } : { planS3Uri: event.PlanS3Uri }), chunkCount: event.ChunkS3Uris.length, @@ -278,7 +319,7 @@ function primeRuntimeEnv(): void { // The v1 handler owns one transactional download, plan, archive, upload, and cleanup lifecycle. // fallow-ignore-next-line complexity async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise { - if (event.PlanProtocol === "v2") { + if (event.PlanProtocol !== "v1") { return handlePlanV2(event, deps); } const started = Date.now(); @@ -358,7 +399,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise, + event: PlanV2Event, deps?: HandlerDeps, ): Promise> { const started = Date.now(); @@ -412,7 +453,7 @@ async function handleRenderChunk( event: RenderChunkEvent, deps?: HandlerDeps, ): Promise { - if (event.PlanProtocol === "v2") { + if (event.PlanProtocol !== "v1") { return handleRenderChunkV2(event, deps); } const started = Date.now(); @@ -481,7 +522,7 @@ async function handleRenderChunk( // render, and upload in one lifecycle so cleanup and errors remain atomic. // fallow-ignore-next-line complexity async function handleRenderChunkV2( - event: Extract, + event: RenderChunkV2Event, deps?: HandlerDeps, ): Promise { const started = Date.now(); @@ -554,7 +595,7 @@ async function handleAssemble( event: AssembleEvent, deps?: HandlerDeps, ): Promise { - if (event.PlanProtocol === "v2") { + if (event.PlanProtocol !== "v1") { return handleAssembleV2(event, deps); } const started = Date.now(); @@ -610,7 +651,7 @@ async function handleAssemble( // keeping the steps local makes its temporary-storage ownership explicit. // fallow-ignore-next-line complexity async function handleAssembleV2( - event: Extract, + event: AssembleV2Event, deps?: HandlerDeps, ): Promise { const started = Date.now(); @@ -776,12 +817,12 @@ function getEventS3Uris(event: PlanEvent | RenderChunkEvent | AssembleEvent): st case "plan": return [event.ProjectS3Uri, event.PlanOutputS3Prefix]; case "renderChunk": - return event.PlanProtocol === "v2" + return event.PlanProtocol !== "v1" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix, event.ChunkOutputS3Prefix] : [event.PlanS3Uri, event.ChunkOutputS3Prefix]; case "assemble": return [ - ...(event.PlanProtocol === "v2" + ...(event.PlanProtocol !== "v1" ? [event.PlanV2ManifestS3Uri, event.PlanV2ArtifactS3Prefix] : [event.PlanS3Uri]), ...event.ChunkS3Uris, diff --git a/packages/aws-lambda/src/sdk/renderToLambda.test.ts b/packages/aws-lambda/src/sdk/renderToLambda.test.ts index 61402c2ad..bb9007bb0 100644 --- a/packages/aws-lambda/src/sdk/renderToLambda.test.ts +++ b/packages/aws-lambda/src/sdk/renderToLambda.test.ts @@ -98,11 +98,11 @@ describe("renderToLambda", () => { PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-1/", OutputS3Uri: "s3://test-bucket/renders/smoke-1/output.mp4", Config: baseConfig, - PlanProtocol: "v1", + PlanProtocol: "v2", }); }); - it("opts the complete execution into plan protocol v2 explicitly", async () => { + it("preserves explicit plan protocol v1 compatibility", async () => { const sfn = new FakeSFN(); const s3 = new FakeS3(); const handle = await renderToLambda({ @@ -110,18 +110,18 @@ describe("renderToLambda", () => { bucketName: "test-bucket", stateMachineArn: "arn:aws:states:us-east-1:1234:stateMachine:hf", config: baseConfig, - executionName: "smoke-v2", - planProtocol: "v2", + executionName: "smoke-v1", + planProtocol: "v1", sfn: asSFNClient(sfn), s3: asS3Client(s3), }); expect(sfn.starts[0]?.input).toEqual({ ProjectS3Uri: handle.projectS3Uri, - PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v2/", - OutputS3Uri: "s3://test-bucket/renders/smoke-v2/output.mp4", + PlanOutputS3Prefix: "s3://test-bucket/renders/smoke-v1/", + OutputS3Uri: "s3://test-bucket/renders/smoke-v1/output.mp4", Config: baseConfig, - PlanProtocol: "v2", + PlanProtocol: "v1", }); }); diff --git a/packages/aws-lambda/src/sdk/renderToLambda.ts b/packages/aws-lambda/src/sdk/renderToLambda.ts index 8df5e20c1..1232e6876 100644 --- a/packages/aws-lambda/src/sdk/renderToLambda.ts +++ b/packages/aws-lambda/src/sdk/renderToLambda.ts @@ -38,8 +38,8 @@ export interface RenderToLambdaOptions { /** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */ config: SerializableDistributedRenderConfig; /** - * Distributed plan transport. Defaults to `"v1"` for backwards - * compatibility. New integrations should explicitly select `"v2"`. + * Distributed plan transport. Defaults to `"v2"`. Select `"v1"` + * explicitly only for deprecated compatibility with the monolithic plan. */ planProtocol?: LambdaPlanProtocol; /** S3 bucket from the SAM stack output (`RenderBucketName`). */ @@ -115,7 +115,7 @@ export async function renderToLambda(opts: RenderToLambdaOptions): Promise { OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4", ServiceUrl: "https://render-abc.run.app", Config: config, - PlanProtocol: "v1", + PlanProtocol: "v2", }); expect(fake.lastParent).toBe( "projects/proj/locations/us-central1/workflows/hyperframes-render", ); }); - it("forwards an explicit v2 whole-render opt-in", async () => { + it("preserves explicit plan protocol v1 compatibility", async () => { const fake = new FakeExecutions(); - await renderToCloudRun({ ...opts(fake), planProtocol: "v2" }); + await renderToCloudRun({ ...opts(fake), planProtocol: "v1" }); const arg = JSON.parse(fake.lastArgument ?? "{}"); expect(arg).toEqual({ RenderId: "hf-render-fixed", @@ -106,7 +106,7 @@ describe("renderToCloudRun", () => { OutputGcsUri: "gs://b/renders/hf-render-fixed/output.mp4", ServiceUrl: "https://render-abc.run.app", Config: config, - PlanProtocol: "v2", + PlanProtocol: "v1", }); }); diff --git a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts index ae07d2e91..55cb20a3c 100644 --- a/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts +++ b/packages/gcp-cloud-run/src/sdk/renderToCloudRun.ts @@ -53,8 +53,8 @@ export interface RenderToCloudRunOptions { /** Validated `SerializableDistributedRenderConfig` (no logger / abortSignal). */ config: SerializableDistributedRenderConfig; /** - * Distributed plan transport. Defaults to `"v1"` for backwards - * compatibility. New integrations should explicitly select `"v2"`. + * Distributed plan transport. Defaults to `"v2"`. Select `"v1"` + * explicitly only for deprecated compatibility with the monolithic plan. */ planProtocol?: CloudRunPlanProtocol; /** GCS bucket from the Terraform output (`render_bucket_name`). */ @@ -149,7 +149,7 @@ export async function renderToCloudRun(opts: RenderToCloudRunOptions): Promise { }); describe("dispatch", () => { - it("routes plan, uploads the plan tarball", async () => { + it("preserves explicit v1 plan compatibility", async () => { const gcs = new FakeGcs(); await seedProjectTar(gcs, "gs://b/sites/x/project.tar.gz"); const event: PlanEvent = { Action: "plan", + PlanProtocol: "v1", ProjectGcsUri: "gs://b/sites/x/project.tar.gz", PlanOutputGcsPrefix: "gs://b/renders/r1/", Config: { fps: 30, width: 1920, height: 1080, format: "mp4" } as PlanEvent["Config"], @@ -201,6 +202,7 @@ describe("dispatch", () => { await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); const event: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 2, @@ -220,6 +222,7 @@ describe("dispatch", () => { await seedPlanTar(gcs, "gs://b/renders/r1/plan.tar.gz", PLAN_HASH); const event: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: "WRONG_HASH", ChunkIndex: 0, @@ -236,6 +239,7 @@ describe("dispatch", () => { gcs.seed("gs://b/renders/r1/chunks/0001.mp4", Buffer.from("c1")); const event: AssembleEvent = { Action: "assemble", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", ChunkGcsUris: ["gs://b/renders/r1/chunks/0000.mp4", "gs://b/renders/r1/chunks/0001.mp4"], AudioGcsUri: null, @@ -251,7 +255,7 @@ describe("dispatch", () => { // This end-to-end adapter contract is intentionally one narrative test: it // verifies ordering and target isolation across all three handler roles. // fallow-ignore-next-line complexity - it("runs v2 plan → target-scoped chunk → assemble with manifest-last CAS", async () => { + it("defaults omitted plan protocol to v2 across plan → chunk → assemble", async () => { const gcs = new FakeGcs(); await seedProjectTar(gcs, "gs://b/sites/v2/project.tar.gz"); const root = mkTmp("hf-v2-e2e-"); @@ -306,7 +310,6 @@ describe("dispatch", () => { const planned = await dispatch( { Action: "plan", - PlanProtocol: "v2", ProjectGcsUri: "gs://b/sites/v2/project.tar.gz", PlanOutputGcsPrefix: "gs://b/renders/v2/", Config: { fps: 30, width: 640, height: 360, format: "mp4" }, @@ -326,7 +329,6 @@ describe("dispatch", () => { await dispatch( { Action: "plan", - PlanProtocol: "v2", ProjectGcsUri: "gs://b/sites/v2/project.tar.gz", PlanOutputGcsPrefix: "gs://b/renders/v2/", Config: { fps: 30, width: 640, height: 360, format: "mp4" }, @@ -342,7 +344,6 @@ describe("dispatch", () => { const chunk = await dispatch( { Action: "renderChunk", - PlanProtocol: "v2", PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri, PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix, PlanHash: planned.PlanHash, @@ -360,7 +361,6 @@ describe("dispatch", () => { await dispatch( { Action: "assemble", - PlanProtocol: "v2", PlanV2ManifestGcsUri: planned.PlanV2ManifestGcsUri, PlanV2ArtifactGcsPrefix: planned.PlanV2ArtifactGcsPrefix, PlanHash: planned.PlanHash, @@ -409,6 +409,7 @@ describe("bucket allowlist guard", () => { try { const event: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://evil-bucket/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, @@ -451,6 +452,7 @@ describe("bucket allowlist guard", () => { try { const event: RenderChunkEvent = { Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://any-bucket/renders/r1/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, @@ -476,6 +478,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, @@ -497,6 +500,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: "WRONG", ChunkIndex: 0, @@ -524,6 +528,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, @@ -561,6 +566,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, @@ -598,6 +604,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/plan.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, @@ -626,6 +633,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "plan", + PlanProtocol: "v1", ProjectGcsUri: "gs://b/sites/invalid-video-metadata/project.tar.gz", PlanOutputGcsPrefix: "gs://b/renders/invalid-video-metadata/", Config: { fps: 30, width: 640, height: 360, format: "mp4" }, @@ -645,6 +653,7 @@ describe("createApp HTTP mapping", () => { headers: { "content-type": "application/json" }, body: JSON.stringify({ Action: "renderChunk", + PlanProtocol: "v1", PlanGcsUri: "gs://b/renders/r1/missing.tar.gz", PlanHash: PLAN_HASH, ChunkIndex: 0, diff --git a/packages/gcp-cloud-run/src/server.ts b/packages/gcp-cloud-run/src/server.ts index 81aae4c2b..8d467ec7a 100644 --- a/packages/gcp-cloud-run/src/server.ts +++ b/packages/gcp-cloud-run/src/server.ts @@ -46,13 +46,16 @@ import { import { resolveChromeExecutablePath } from "./chromium.js"; import type { AssembleEvent, + AssembleV2Event, AssembleResultBody, CloudRunAction, CloudRunEvent, CloudRunResult, PlanEvent, + PlanV2Event, PlanResultBody, RenderChunkEvent, + RenderChunkV2Event, RenderChunkResultBody, } from "./events.js"; import { type DistributedFormat, formatExtension } from "./formatExtension.js"; @@ -155,21 +158,22 @@ function validatePlanProtocolShape(event: PlanEvent | RenderChunkEvent | Assembl } if (event.Action === "plan") return; + const effectiveProtocol = protocol ?? "v2"; const hasV1Locator = typeof raw.PlanGcsUri === "string"; const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string"; const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string"; const valid = - protocol === "v2" + effectiveProtocol === "v2" ? !hasV1Locator && hasV2Manifest && hasV2Prefix : hasV1Locator && !hasV2Manifest && !hasV2Prefix; if (!valid) { const error = new Error( - `[handler] ${protocol === "v2" ? "v2" : "v1"} ${event.Action} event has mixed or missing plan locators`, + `[handler] ${effectiveProtocol} ${event.Action} event has mixed or missing plan locators`, ); error.name = "PLAN_PROTOCOL_UNSUPPORTED"; throw error; } - if (protocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) { + if (effectiveProtocol === "v2" && event.Action === "assemble" && event.AudioGcsUri !== null) { const error = new Error("[handler] v2 assemble audio must be materialized from the manifest"); error.name = "PLAN_PROTOCOL_UNSUPPORTED"; throw error; @@ -254,14 +258,14 @@ function summarizeEvent( return { projectGcsUri: event.ProjectGcsUri, planOutputGcsPrefix: event.PlanOutputGcsPrefix, - planProtocol: event.PlanProtocol ?? "v1", + planProtocol: event.PlanProtocol ?? "v2", format: event.Config.format, fps: event.Config.fps, }; case "renderChunk": return { - planProtocol: event.PlanProtocol ?? "v1", - ...(event.PlanProtocol === "v2" + planProtocol: event.PlanProtocol ?? "v2", + ...(event.PlanProtocol !== "v1" ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri } : { planGcsUri: event.PlanGcsUri }), chunkIndex: event.ChunkIndex, @@ -269,8 +273,8 @@ function summarizeEvent( }; case "assemble": return { - planProtocol: event.PlanProtocol ?? "v1", - ...(event.PlanProtocol === "v2" + planProtocol: event.PlanProtocol ?? "v2", + ...(event.PlanProtocol !== "v1" ? { planV2ManifestGcsUri: event.PlanV2ManifestGcsUri } : { planGcsUri: event.PlanGcsUri }), chunkCount: event.ChunkGcsUris.length, @@ -297,7 +301,7 @@ function primeChrome(deps?: HandlerDeps): void { // fallow-ignore-next-line complexity async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise { - if (event.PlanProtocol === "v2") { + if (event.PlanProtocol !== "v1") { return handlePlanV2(event, deps); } const started = Date.now(); @@ -365,7 +369,7 @@ async function handlePlan(event: PlanEvent, deps?: HandlerDeps): Promise, + event: PlanV2Event, deps?: HandlerDeps, ): Promise> { const started = Date.now(); @@ -418,7 +422,7 @@ async function handleRenderChunk( event: RenderChunkEvent, deps?: HandlerDeps, ): Promise { - if (event.PlanProtocol === "v2") { + if (event.PlanProtocol !== "v1") { return handleRenderChunkV2(event, deps); } const started = Date.now(); @@ -475,7 +479,7 @@ async function handleRenderChunk( /** Materialize only this chunk's verified v2 dependencies before rendering. */ // fallow-ignore-next-line complexity async function handleRenderChunkV2( - event: Extract, + event: RenderChunkV2Event, deps?: HandlerDeps, ): Promise { const started = Date.now(); @@ -548,7 +552,7 @@ async function handleAssemble( event: AssembleEvent, deps?: HandlerDeps, ): Promise { - if (event.PlanProtocol === "v2") { + if (event.PlanProtocol !== "v1") { return handleAssembleV2(event, deps); } const started = Date.now(); @@ -613,7 +617,7 @@ async function handleAssemble( */ // fallow-ignore-next-line complexity async function handleAssembleV2( - event: Extract, + event: AssembleV2Event, deps?: HandlerDeps, ): Promise { const started = Date.now(); @@ -781,12 +785,12 @@ function getEventGcsUris(event: PlanEvent | RenderChunkEvent | AssembleEvent): s case "plan": return [event.ProjectGcsUri, event.PlanOutputGcsPrefix]; case "renderChunk": - return event.PlanProtocol === "v2" + return event.PlanProtocol !== "v1" ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix, event.ChunkOutputGcsPrefix] : [event.PlanGcsUri, event.ChunkOutputGcsPrefix]; case "assemble": return [ - ...(event.PlanProtocol === "v2" + ...(event.PlanProtocol !== "v1" ? [event.PlanV2ManifestGcsUri, event.PlanV2ArtifactGcsPrefix] : [event.PlanGcsUri]), ...event.ChunkGcsUris, diff --git a/packages/gcp-cloud-run/terraform/smoke-safety.test.ts b/packages/gcp-cloud-run/terraform/smoke-safety.test.ts index 4ba0df321..faaa03b1d 100644 --- a/packages/gcp-cloud-run/terraform/smoke-safety.test.ts +++ b/packages/gcp-cloud-run/terraform/smoke-safety.test.ts @@ -7,8 +7,8 @@ const smoke = readFileSync(smokePath, "utf-8"); const dockerfile = readFileSync(join(import.meta.dir, "../Dockerfile"), "utf-8"); describe("GCP smoke ownership and protocol safety", () => { - it("defaults to v1 and requires an explicit v2 protocol argument", () => { - expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v1}"'); + it("defaults to v2 and retains explicit v1/v2 protocol arguments", () => { + expect(smoke).toContain('PROTOCOLS="${PROTOCOLS:-v2}"'); expect(smoke).toContain("--protocols)"); expect(smoke).toContain("PlanProtocol: $protocol"); expect(smoke).toContain("decodedFramesEqual"); diff --git a/packages/gcp-cloud-run/terraform/workflow.test.ts b/packages/gcp-cloud-run/terraform/workflow.test.ts index b17e00772..e4c9f5d92 100644 --- a/packages/gcp-cloud-run/terraform/workflow.test.ts +++ b/packages/gcp-cloud-run/terraform/workflow.test.ts @@ -77,8 +77,8 @@ describe("Cloud Workflows plan protocol routing", () => { expect(source.match(/max_retries: 4/g)).toHaveLength(4); }); - it("keeps v1 as the default and rejects unknown protocols before plan", () => { - expect(source).toContain('default(map.get(args, "PlanProtocol"), "v1")'); + it("defaults omitted protocol to v2 and rejects unknown protocols before plan", () => { + expect(source).toContain('default(map.get(args, "PlanProtocol"), "v2")'); expect(namedStep("selectPlanProtocol")).toMatchObject({ next: "unsupportedPlanProtocol", }); diff --git a/packages/gcp-cloud-run/terraform/workflow.yaml b/packages/gcp-cloud-run/terraform/workflow.yaml index 7901a5a2b..2e563a489 100644 --- a/packages/gcp-cloud-run/terraform/workflow.yaml +++ b/packages/gcp-cloud-run/terraform/workflow.yaml @@ -26,9 +26,9 @@ main: - planOutputGcsPrefix: ${args.PlanOutputGcsPrefix} - outputGcsUri: ${args.OutputGcsUri} - config: ${args.Config} - # Backward-compatible default. v2 is accepted only through an - # explicit top-level PlanProtocol opt-in. - - planProtocol: ${default(map.get(args, "PlanProtocol"), "v1")} + # Plan v2 is the default. Explicit v1 remains available during the + # deprecated monolithic-plan compatibility window. + - planProtocol: ${default(map.get(args, "PlanProtocol"), "v2")} # ── Plan (Activity A) ──────────────────────────────────────────────────── - selectPlanProtocol: