@hyperframes/gcp-cloud-run 0.8.230 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -16
- package/dist/events.d.ts +14 -75
- package/dist/index.d.ts +1 -1
- package/dist/index.js +30 -168
- package/dist/sdk/index.js +11 -1
- package/dist/sdk/renderToCloudRun.d.ts +1 -6
- package/dist/server.d.ts +1 -2
- package/dist/server.js +16 -164
- package/package.json +2 -2
- package/terraform/smoke-safety.test.ts +4 -6
- package/terraform/workflow.test.ts +34 -108
- package/terraform/workflow.yaml +27 -168
package/dist/server.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/server.ts
|
|
2
|
-
import {
|
|
2
|
+
import { mkdirSync as mkdirSync3, mkdtempSync as mkdtempSync2, rmSync as rmSync3 } from "node:fs";
|
|
3
3
|
import { tmpdir as tmpdir2 } from "node:os";
|
|
4
4
|
import { basename, extname, join as join2 } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -10,9 +10,7 @@ import {
|
|
|
10
10
|
assemble,
|
|
11
11
|
listPlanV2ArtifactsForTarget,
|
|
12
12
|
materializePlanV2Target,
|
|
13
|
-
plan,
|
|
14
13
|
isPlanAudioArtifactPath,
|
|
15
|
-
PLAN_AUDIO_RELATIVE_PATH,
|
|
16
14
|
resolvePlanAudioPath,
|
|
17
15
|
planV2WithPublisher,
|
|
18
16
|
readPlanV2Manifest,
|
|
@@ -380,27 +378,26 @@ async function dispatch(event, deps) {
|
|
|
380
378
|
function validatePlanProtocolShape(event) {
|
|
381
379
|
const raw = event;
|
|
382
380
|
const protocol = raw.PlanProtocol;
|
|
383
|
-
if (protocol !== void 0 && protocol !== "
|
|
381
|
+
if (protocol !== void 0 && protocol !== "v2") {
|
|
384
382
|
const error = new Error(
|
|
385
|
-
`[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "
|
|
383
|
+
`[handler] unsupported PlanProtocol ${JSON.stringify(protocol)}; expected "v2" or absent`
|
|
386
384
|
);
|
|
387
385
|
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
388
386
|
throw error;
|
|
389
387
|
}
|
|
390
388
|
if (event.Action === "plan") return;
|
|
391
|
-
const effectiveProtocol = protocol ?? "v2";
|
|
392
389
|
const hasV1Locator = typeof raw.PlanGcsUri === "string";
|
|
393
390
|
const hasV2Manifest = typeof raw.PlanV2ManifestGcsUri === "string";
|
|
394
391
|
const hasV2Prefix = typeof raw.PlanV2ArtifactGcsPrefix === "string";
|
|
395
|
-
const valid =
|
|
392
|
+
const valid = !hasV1Locator && hasV2Manifest && hasV2Prefix;
|
|
396
393
|
if (!valid) {
|
|
397
394
|
const error = new Error(
|
|
398
|
-
`[handler]
|
|
395
|
+
`[handler] v2 ${event.Action} event has mixed or missing plan locators`
|
|
399
396
|
);
|
|
400
397
|
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
401
398
|
throw error;
|
|
402
399
|
}
|
|
403
|
-
if (
|
|
400
|
+
if (event.Action === "assemble" && event.AudioGcsUri !== null) {
|
|
404
401
|
const error = new Error("[handler] v2 assemble audio must be materialized from the manifest");
|
|
405
402
|
error.name = "PLAN_PROTOCOL_UNSUPPORTED";
|
|
406
403
|
throw error;
|
|
@@ -409,7 +406,7 @@ function validatePlanProtocolShape(event) {
|
|
|
409
406
|
function normalizeTerminalErrorName(error) {
|
|
410
407
|
if (!error || typeof error !== "object") return;
|
|
411
408
|
const candidate = error;
|
|
412
|
-
if (candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" || candidate.code === "
|
|
409
|
+
if (candidate.code === "PLAN_PROTOCOL_UNSUPPORTED" || candidate.code === "PLAN_V2_INTEGRITY_UNRECOVERABLE" || candidate.code === "FONT_FETCH_FAILED" || candidate.code === "FONT_FETCH_UNAVAILABLE" || candidate.code === "VIDEO_SOURCE_UNRENDERABLE" || candidate.code === "VIDEO_EXTRACTION_FAILED" || candidate.code === "INVALID_VIDEO_METADATA" || candidate.code === "CHUNK_EXCEEDS_WORKER_TIMEOUT") {
|
|
413
410
|
candidate.name = candidate.code;
|
|
414
411
|
}
|
|
415
412
|
}
|
|
@@ -456,14 +453,14 @@ function summarizeEvent(event) {
|
|
|
456
453
|
case "renderChunk":
|
|
457
454
|
return {
|
|
458
455
|
planProtocol: event.PlanProtocol ?? "v2",
|
|
459
|
-
|
|
456
|
+
planV2ManifestGcsUri: event.PlanV2ManifestGcsUri,
|
|
460
457
|
chunkIndex: event.ChunkIndex,
|
|
461
458
|
format: event.Format
|
|
462
459
|
};
|
|
463
460
|
case "assemble":
|
|
464
461
|
return {
|
|
465
462
|
planProtocol: event.PlanProtocol ?? "v2",
|
|
466
|
-
|
|
463
|
+
planV2ManifestGcsUri: event.PlanV2ManifestGcsUri,
|
|
467
464
|
chunkCount: event.ChunkGcsUris.length,
|
|
468
465
|
hasAudio: event.AudioGcsUri !== null,
|
|
469
466
|
outputGcsUri: event.OutputGcsUri,
|
|
@@ -477,51 +474,6 @@ function primeChrome(deps) {
|
|
|
477
474
|
process.env.PRODUCER_HEADLESS_SHELL_PATH = resolveChromeExecutablePath();
|
|
478
475
|
}
|
|
479
476
|
async function handlePlan(event, deps) {
|
|
480
|
-
if (event.PlanProtocol !== "v1") {
|
|
481
|
-
return handlePlanV2(event, deps);
|
|
482
|
-
}
|
|
483
|
-
const started = Date.now();
|
|
484
|
-
const storage = deps?.storage ?? getStorage();
|
|
485
|
-
const primitive = deps?.primitives?.plan ?? plan;
|
|
486
|
-
primeChrome(deps);
|
|
487
|
-
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-cr-plan-"));
|
|
488
|
-
const projectArchive = join2(work, "project.tar.gz");
|
|
489
|
-
const projectDir = join2(work, "project");
|
|
490
|
-
const planDir = join2(work, "plan");
|
|
491
|
-
try {
|
|
492
|
-
await downloadGcsObjectToFile(storage, event.ProjectGcsUri, projectArchive);
|
|
493
|
-
await untarDirectory(projectArchive, projectDir);
|
|
494
|
-
const config = {
|
|
495
|
-
...event.Config
|
|
496
|
-
};
|
|
497
|
-
const result = await primitive(projectDir, config, planDir);
|
|
498
|
-
const planTar = join2(work, "plan.tar.gz");
|
|
499
|
-
await tarDirectory(planDir, planTar);
|
|
500
|
-
const planTarUri = `${trimTrailingSlash2(event.PlanOutputGcsPrefix)}/plan.tar.gz`;
|
|
501
|
-
const audioPath = join2(planDir, PLAN_AUDIO_RELATIVE_PATH);
|
|
502
|
-
const hasAudio = existsSync3(audioPath) && statSync3(audioPath).size > 0;
|
|
503
|
-
await uploadFileToGcs(storage, planTar, planTarUri, "application/gzip");
|
|
504
|
-
return {
|
|
505
|
-
Action: "plan",
|
|
506
|
-
PlanGcsUri: planTarUri,
|
|
507
|
-
PlanHash: result.planHash,
|
|
508
|
-
ChunkCount: result.chunkCount,
|
|
509
|
-
TotalFrames: result.totalFrames,
|
|
510
|
-
Fps: result.fps,
|
|
511
|
-
Width: result.width,
|
|
512
|
-
Height: result.height,
|
|
513
|
-
Format: result.format,
|
|
514
|
-
HasAudio: hasAudio,
|
|
515
|
-
AudioGcsUri: null,
|
|
516
|
-
FfmpegVersion: result.ffmpegVersion,
|
|
517
|
-
ProducerVersion: result.producerVersion,
|
|
518
|
-
DurationMs: Date.now() - started
|
|
519
|
-
};
|
|
520
|
-
} finally {
|
|
521
|
-
cleanupDir(work);
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
async function handlePlanV2(event, deps) {
|
|
525
477
|
const started = Date.now();
|
|
526
478
|
const storage = deps?.storage ?? getStorage();
|
|
527
479
|
const primitive = deps?.primitives?.planV2WithPublisher ?? planV2WithPublisher;
|
|
@@ -563,45 +515,6 @@ async function handlePlanV2(event, deps) {
|
|
|
563
515
|
}
|
|
564
516
|
}
|
|
565
517
|
async function handleRenderChunk(event, deps) {
|
|
566
|
-
if (event.PlanProtocol !== "v1") {
|
|
567
|
-
return handleRenderChunkV2(event, deps);
|
|
568
|
-
}
|
|
569
|
-
const started = Date.now();
|
|
570
|
-
const storage = deps?.storage ?? getStorage();
|
|
571
|
-
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
|
572
|
-
primeChrome(deps);
|
|
573
|
-
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-cr-chunk-"));
|
|
574
|
-
const planTar = join2(work, "plan.tar.gz");
|
|
575
|
-
const planDir = join2(work, "plan");
|
|
576
|
-
try {
|
|
577
|
-
await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar);
|
|
578
|
-
await untarDirectory(planTar, planDir);
|
|
579
|
-
verifyPlanHash(planDir, event.PlanHash);
|
|
580
|
-
const chunkOutputBase = join2(
|
|
581
|
-
work,
|
|
582
|
-
event.Format === "png-sequence" ? `chunk-${pad(event.ChunkIndex)}` : `chunk-${pad(event.ChunkIndex)}${formatExtension(event.Format)}`
|
|
583
|
-
);
|
|
584
|
-
const result = await primitive(planDir, event.ChunkIndex, chunkOutputBase);
|
|
585
|
-
const chunkUri = await uploadChunkOutput(
|
|
586
|
-
storage,
|
|
587
|
-
result,
|
|
588
|
-
event.ChunkOutputGcsPrefix,
|
|
589
|
-
event.ChunkIndex
|
|
590
|
-
);
|
|
591
|
-
return {
|
|
592
|
-
Action: "renderChunk",
|
|
593
|
-
ChunkGcsUri: chunkUri,
|
|
594
|
-
ChunkIndex: event.ChunkIndex,
|
|
595
|
-
Sha256: result.sha256,
|
|
596
|
-
FramesEncoded: result.framesEncoded,
|
|
597
|
-
CaptureMode: result.captureMode,
|
|
598
|
-
DurationMs: Date.now() - started
|
|
599
|
-
};
|
|
600
|
-
} finally {
|
|
601
|
-
cleanupDir(work);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
async function handleRenderChunkV2(event, deps) {
|
|
605
518
|
const started = Date.now();
|
|
606
519
|
const storage = deps?.storage ?? getStorage();
|
|
607
520
|
const primitive = deps?.primitives?.renderChunk ?? renderChunk;
|
|
@@ -653,50 +566,6 @@ async function uploadChunkOutput(storage, result, prefix, chunkIndex) {
|
|
|
653
566
|
return uri;
|
|
654
567
|
}
|
|
655
568
|
async function handleAssemble(event, deps) {
|
|
656
|
-
if (event.PlanProtocol !== "v1") {
|
|
657
|
-
return handleAssembleV2(event, deps);
|
|
658
|
-
}
|
|
659
|
-
const started = Date.now();
|
|
660
|
-
const storage = deps?.storage ?? getStorage();
|
|
661
|
-
const primitive = deps?.primitives?.assemble ?? assemble;
|
|
662
|
-
const work = mkdtempSync2(join2(deps?.tmpRoot ?? tmpdir2(), "hf-cr-assemble-"));
|
|
663
|
-
const planTar = join2(work, "plan.tar.gz");
|
|
664
|
-
const planDir = join2(work, "plan");
|
|
665
|
-
try {
|
|
666
|
-
await downloadGcsObjectToFile(storage, event.PlanGcsUri, planTar);
|
|
667
|
-
await untarDirectory(planTar, planDir);
|
|
668
|
-
const chunkPaths = await downloadChunkObjects(storage, event.ChunkGcsUris, work, event.Format);
|
|
669
|
-
let audioPath = null;
|
|
670
|
-
const planAudio = resolvePlanAudioPath(planDir) ?? join2(planDir, PLAN_AUDIO_RELATIVE_PATH);
|
|
671
|
-
if (existsSync3(planAudio) && statSync3(planAudio).size > 0) {
|
|
672
|
-
audioPath = planAudio;
|
|
673
|
-
} else if (event.AudioGcsUri) {
|
|
674
|
-
audioPath = planAudio;
|
|
675
|
-
await downloadGcsObjectToFile(storage, event.AudioGcsUri, audioPath);
|
|
676
|
-
}
|
|
677
|
-
const finalOutput = event.Format === "png-sequence" ? join2(work, "output-frames") : join2(work, `output${formatExtension(event.Format)}`);
|
|
678
|
-
const result = await primitive(planDir, chunkPaths, audioPath, finalOutput, {
|
|
679
|
-
cfr: event.Cfr === true
|
|
680
|
-
});
|
|
681
|
-
if (event.Format === "png-sequence") {
|
|
682
|
-
const tarball = `${finalOutput}.tar.gz`;
|
|
683
|
-
await tarDirectory(finalOutput, tarball);
|
|
684
|
-
await uploadFileToGcs(storage, tarball, event.OutputGcsUri, "application/gzip");
|
|
685
|
-
} else {
|
|
686
|
-
await uploadFileToGcs(storage, finalOutput, event.OutputGcsUri);
|
|
687
|
-
}
|
|
688
|
-
return {
|
|
689
|
-
Action: "assemble",
|
|
690
|
-
OutputGcsUri: event.OutputGcsUri,
|
|
691
|
-
FramesEncoded: result.framesEncoded,
|
|
692
|
-
FileSize: result.fileSize,
|
|
693
|
-
DurationMs: Date.now() - started
|
|
694
|
-
};
|
|
695
|
-
} finally {
|
|
696
|
-
cleanupDir(work);
|
|
697
|
-
}
|
|
698
|
-
}
|
|
699
|
-
async function handleAssembleV2(event, deps) {
|
|
700
569
|
const started = Date.now();
|
|
701
570
|
const storage = deps?.storage ?? getStorage();
|
|
702
571
|
const primitive = deps?.primitives?.assemble ?? assemble;
|
|
@@ -815,10 +684,15 @@ function getEventGcsUris(event) {
|
|
|
815
684
|
case "plan":
|
|
816
685
|
return [event.ProjectGcsUri, event.PlanOutputGcsPrefix];
|
|
817
686
|
case "renderChunk":
|
|
818
|
-
return
|
|
687
|
+
return [
|
|
688
|
+
event.PlanV2ManifestGcsUri,
|
|
689
|
+
event.PlanV2ArtifactGcsPrefix,
|
|
690
|
+
event.ChunkOutputGcsPrefix
|
|
691
|
+
];
|
|
819
692
|
case "assemble":
|
|
820
693
|
return [
|
|
821
|
-
|
|
694
|
+
event.PlanV2ManifestGcsUri,
|
|
695
|
+
event.PlanV2ArtifactGcsPrefix,
|
|
822
696
|
...event.ChunkGcsUris,
|
|
823
697
|
event.OutputGcsUri,
|
|
824
698
|
event.AudioGcsUri
|
|
@@ -863,26 +737,6 @@ function cleanupDir(dir) {
|
|
|
863
737
|
} catch {
|
|
864
738
|
}
|
|
865
739
|
}
|
|
866
|
-
function verifyPlanHash(planDir, expected) {
|
|
867
|
-
const planJsonPath = join2(planDir, "plan.json");
|
|
868
|
-
let parsed;
|
|
869
|
-
try {
|
|
870
|
-
parsed = JSON.parse(readFileSync(planJsonPath, "utf-8"));
|
|
871
|
-
} catch (err) {
|
|
872
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
873
|
-
const error = new Error(`PLAN_HASH_MISMATCH: failed to read ${planJsonPath}: ${msg}`);
|
|
874
|
-
error.name = "PLAN_HASH_MISMATCH";
|
|
875
|
-
throw error;
|
|
876
|
-
}
|
|
877
|
-
const actual = parsed.planHash;
|
|
878
|
-
if (typeof actual !== "string" || actual !== expected) {
|
|
879
|
-
const error = new Error(
|
|
880
|
-
`PLAN_HASH_MISMATCH: event PlanHash=${expected} did not match plan.json planHash=${String(actual)}`
|
|
881
|
-
);
|
|
882
|
-
error.name = "PLAN_HASH_MISMATCH";
|
|
883
|
-
throw error;
|
|
884
|
-
}
|
|
885
|
-
}
|
|
886
740
|
var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
887
741
|
// Handler-boundary guards.
|
|
888
742
|
"GCS_URI_NOT_ALLOWED",
|
|
@@ -900,13 +754,11 @@ var NON_RETRYABLE_ERROR_NAMES = /* @__PURE__ */ new Set([
|
|
|
900
754
|
// non-retryable list.
|
|
901
755
|
"FormatNotSupportedInDistributedError",
|
|
902
756
|
"NotMediaPayloadError",
|
|
903
|
-
"PlanTooLargeError",
|
|
904
757
|
"PlanProtocolUnsupportedError",
|
|
905
758
|
"PlanV2IntegrityError",
|
|
906
759
|
"RenderChunkValidationError",
|
|
907
760
|
"FFMPEG_VERSION_MISMATCH",
|
|
908
761
|
"FORMAT_NOT_SUPPORTED_IN_DISTRIBUTED",
|
|
909
|
-
"PLAN_TOO_LARGE",
|
|
910
762
|
"BROWSER_GPU_NOT_SOFTWARE",
|
|
911
763
|
"FONT_FETCH_FAILED",
|
|
912
764
|
"ChromeBinaryUnavailableError"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyperframes/gcp-cloud-run",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"hono": "^4.6.0",
|
|
46
46
|
"puppeteer-core": "^25.8.0",
|
|
47
47
|
"tar": "^7.4.3",
|
|
48
|
-
"@hyperframes/producer": "^0.
|
|
48
|
+
"@hyperframes/producer": "^0.9.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@types/node": "^25.0.10",
|
|
@@ -7,13 +7,11 @@ const smoke = readFileSync(smokePath, "utf-8");
|
|
|
7
7
|
const dockerfile = readFileSync(join(import.meta.dir, "../Dockerfile"), "utf-8");
|
|
8
8
|
|
|
9
9
|
describe("GCP smoke ownership and protocol safety", () => {
|
|
10
|
-
it("
|
|
11
|
-
expect(smoke).toContain('
|
|
12
|
-
expect(smoke).toContain("--protocols)");
|
|
10
|
+
it("runs only the Plan v2 protocol", () => {
|
|
11
|
+
expect(smoke).toContain('PROTOCOL="v2"');
|
|
12
|
+
expect(smoke).not.toContain("--protocols)");
|
|
13
13
|
expect(smoke).toContain("PlanProtocol: $protocol");
|
|
14
|
-
expect(smoke).toContain("
|
|
15
|
-
expect(smoke).toContain("decodedAudioEqual");
|
|
16
|
-
expect(smoke).toContain("normalizedMetadataEqual");
|
|
14
|
+
expect(smoke).not.toContain("PlanProtocol: v1");
|
|
17
15
|
expect(smoke).toContain('.CaptureMode == "beginframe"');
|
|
18
16
|
expect(smoke).toContain("expected every chunk to use beginframe");
|
|
19
17
|
});
|
|
@@ -6,19 +6,12 @@ import { parse } from "yaml";
|
|
|
6
6
|
type Step = Record<string, unknown>;
|
|
7
7
|
|
|
8
8
|
const source = readFileSync(join(import.meta.dir, "workflow.yaml"), "utf-8");
|
|
9
|
-
// Cloud Workflows expressions are valid to Google's parser but `${...}`
|
|
10
|
-
// inside YAML flow collections is not valid generic YAML. Quote expressions
|
|
11
|
-
// for structural parsing while preserving their text for contract assertions.
|
|
12
9
|
const parseableSource = source.replace(/\$\{([^}]*)\}/g, (_match, expression: string) =>
|
|
13
10
|
JSON.stringify(`\${${expression}}`),
|
|
14
11
|
);
|
|
15
12
|
const workflow = parse(parseableSource) as {
|
|
16
|
-
main: {
|
|
17
|
-
|
|
18
|
-
};
|
|
19
|
-
retryable: {
|
|
20
|
-
steps: Step[];
|
|
21
|
-
};
|
|
13
|
+
main: { steps: Step[] };
|
|
14
|
+
retryable: { steps: Step[] };
|
|
22
15
|
};
|
|
23
16
|
|
|
24
17
|
function namedStep(name: string, steps = workflow.main.steps): Record<string, unknown> {
|
|
@@ -28,121 +21,54 @@ function namedStep(name: string, steps = workflow.main.steps): Record<string, un
|
|
|
28
21
|
throw new Error(`missing workflow step ${name}`);
|
|
29
22
|
}
|
|
30
23
|
|
|
31
|
-
function requestBody(stepName:
|
|
32
|
-
|
|
33
|
-
const attempt = step.try as {
|
|
34
|
-
args: {
|
|
35
|
-
body: Record<string, unknown>;
|
|
36
|
-
};
|
|
37
|
-
};
|
|
38
|
-
return attempt.args.body;
|
|
24
|
+
function requestBody(stepName: "plan" | "assemble"): Record<string, unknown> {
|
|
25
|
+
return (namedStep(stepName).try as { args: { body: Record<string, unknown> } }).args.body;
|
|
39
26
|
}
|
|
40
27
|
|
|
41
|
-
function
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
-
|
|
45
|
-
auth: Record<string, unknown>;
|
|
46
|
-
};
|
|
47
|
-
};
|
|
48
|
-
return attempt.args.auth;
|
|
28
|
+
function chunkRequestBody(): Record<string, unknown> {
|
|
29
|
+
const renderChunks = namedStep("renderChunks").parallel as { for: { steps: Step[] } };
|
|
30
|
+
const render = namedStep("renderOneChunk", renderChunks.for.steps);
|
|
31
|
+
return (render.try as { args: { body: Record<string, unknown> } }).args.body;
|
|
49
32
|
}
|
|
50
33
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
const parallel = renderChunks.parallel as {
|
|
54
|
-
for: {
|
|
55
|
-
steps: Step[];
|
|
56
|
-
};
|
|
57
|
-
};
|
|
58
|
-
const step = namedStep(stepName, parallel.for.steps);
|
|
59
|
-
const attempt = step.try as {
|
|
60
|
-
args: {
|
|
61
|
-
body: Record<string, unknown>;
|
|
62
|
-
};
|
|
63
|
-
};
|
|
64
|
-
return attempt.args.body;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe("Cloud Workflows plan protocol routing", () => {
|
|
68
|
-
it("pins OIDC tokens to the Cloud Run root and retries IAM propagation", () => {
|
|
69
|
-
for (const stepName of ["planV1", "planV2", "assembleV1", "assembleV2"]) {
|
|
70
|
-
expect(requestAuth(stepName)).toEqual({
|
|
71
|
-
type: "OIDC",
|
|
72
|
-
audience: "${serviceUrl}",
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
expect(JSON.stringify(workflow.retryable)).toContain("e.code == 403");
|
|
76
|
-
expect(source.match(/max_retries: 6/g)).toHaveLength(2);
|
|
77
|
-
expect(source.match(/max_retries: 4/g)).toHaveLength(4);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("defaults omitted protocol to v2 and rejects unknown protocols before plan", () => {
|
|
34
|
+
describe("Cloud Workflows Plan v2 orchestration", () => {
|
|
35
|
+
it("defaults an omitted protocol to v2 and rejects every explicit non-v2 value", () => {
|
|
81
36
|
expect(source).toContain('default(map.get(args, "PlanProtocol"), "v2")');
|
|
82
|
-
expect(namedStep("
|
|
37
|
+
expect(namedStep("checkPlanProtocol")).toMatchObject({
|
|
38
|
+
switch: [{ condition: '${planProtocol == "v2"}', next: "plan" }],
|
|
83
39
|
next: "unsupportedPlanProtocol",
|
|
84
40
|
});
|
|
85
41
|
expect(namedStep("unsupportedPlanProtocol")).toMatchObject({
|
|
86
|
-
raise: {
|
|
87
|
-
code: "PLAN_PROTOCOL_UNSUPPORTED",
|
|
88
|
-
},
|
|
42
|
+
raise: { code: "PLAN_PROTOCOL_UNSUPPORTED" },
|
|
89
43
|
});
|
|
90
44
|
});
|
|
91
45
|
|
|
92
|
-
it("uses
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
expect(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
46
|
+
it("uses Plan v2 for every distributed action", () => {
|
|
47
|
+
for (const body of [requestBody("plan"), chunkRequestBody(), requestBody("assemble")]) {
|
|
48
|
+
expect(body.PlanProtocol).toBe("v2");
|
|
49
|
+
expect(body).not.toHaveProperty("PlanGcsUri");
|
|
50
|
+
}
|
|
51
|
+
expect(chunkRequestBody()).toHaveProperty("PlanV2ManifestGcsUri");
|
|
52
|
+
expect(chunkRequestBody()).toHaveProperty("PlanV2ArtifactGcsPrefix");
|
|
53
|
+
expect(requestBody("assemble")).toMatchObject({ AudioGcsUri: null });
|
|
54
|
+
expect(source).not.toContain("PlanProtocol: v1");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("validates the v2 plan response before fan-out", () => {
|
|
101
58
|
const validation = JSON.stringify(namedStep("validatePlanResult"));
|
|
102
|
-
expect(validation).toContain("PlanGcsUri");
|
|
103
59
|
expect(validation).toContain("PlanV2ManifestGcsUri");
|
|
104
60
|
expect(validation).toContain("PlanV2ArtifactGcsPrefix");
|
|
105
|
-
expect(
|
|
106
|
-
expect(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("never mixes v1 and v2 chunk locators", () => {
|
|
112
|
-
const v1 = chunkRequestBody("renderOneChunkV1");
|
|
113
|
-
expect(v1).toMatchObject({
|
|
114
|
-
Action: "renderChunk",
|
|
115
|
-
PlanProtocol: "v1",
|
|
116
|
-
});
|
|
117
|
-
expect(v1).toHaveProperty("PlanGcsUri");
|
|
118
|
-
expect(v1).not.toHaveProperty("PlanV2ManifestGcsUri");
|
|
119
|
-
expect(v1).not.toHaveProperty("PlanV2ArtifactGcsPrefix");
|
|
120
|
-
|
|
121
|
-
const v2 = chunkRequestBody("renderOneChunkV2");
|
|
122
|
-
expect(v2).toMatchObject({
|
|
123
|
-
Action: "renderChunk",
|
|
124
|
-
PlanProtocol: "v2",
|
|
61
|
+
expect(validation).toContain("PlanGcsUri");
|
|
62
|
+
expect(validation).toContain("chunkCount > 0");
|
|
63
|
+
expect(namedStep("invalidPlanResult")).toMatchObject({
|
|
64
|
+
raise: { code: "PLAN_RESULT_INVALID" },
|
|
125
65
|
});
|
|
126
|
-
expect(v2).not.toHaveProperty("PlanGcsUri");
|
|
127
|
-
expect(v2).toHaveProperty("PlanV2ManifestGcsUri");
|
|
128
|
-
expect(v2).toHaveProperty("PlanV2ArtifactGcsPrefix");
|
|
129
|
-
expect(v2).toHaveProperty("PlanHash");
|
|
130
66
|
});
|
|
131
67
|
|
|
132
|
-
it("
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
expect(
|
|
136
|
-
expect(
|
|
137
|
-
|
|
138
|
-
const v2 = requestBody("assembleV2");
|
|
139
|
-
expect(v2).not.toHaveProperty("PlanGcsUri");
|
|
140
|
-
expect(v2).toHaveProperty("PlanV2ManifestGcsUri");
|
|
141
|
-
expect(v2).toHaveProperty("PlanV2ArtifactGcsPrefix");
|
|
142
|
-
expect(v2).toHaveProperty("PlanHash");
|
|
143
|
-
expect(v2).toMatchObject({
|
|
144
|
-
PlanProtocol: "v2",
|
|
145
|
-
AudioGcsUri: null,
|
|
146
|
-
});
|
|
68
|
+
it("pins OIDC tokens to the service and retries transient failures", () => {
|
|
69
|
+
expect(source.match(/audience: \$\{serviceUrl\}/g)).toHaveLength(3);
|
|
70
|
+
expect(JSON.stringify(workflow.retryable)).toContain("e.code == 403");
|
|
71
|
+
expect(source.match(/max_retries: 6/g)).toHaveLength(1);
|
|
72
|
+
expect(source.match(/max_retries: 4/g)).toHaveLength(2);
|
|
147
73
|
});
|
|
148
74
|
});
|