# HyperFrames distributed render orchestration on Cloud Workflows.
#
#   Plan → BuildChunkList → AssertChunkCount → RenderChunks (parallel) → Assemble
#
# Mirrors the Step Functions state machine in
# `examples/aws-lambda/template.yaml`. Every step POSTs to the same Cloud Run
# service URL (passed in as `args.ServiceUrl`) and varies only the body's
# `Action`. The service returns the step's small result body on 2xx; on a
# non-retryable failure it returns HTTP 400, on a retryable failure HTTP 5xx —
# the `retryable` predicate below keys off exactly that split.
#
# The final returned object accumulates every step's result body so
# `getRenderProgress` can read frame totals + per-step durations on success:
#   { Plan: {...}, Chunks: [{...}, ...], Assemble: {...} }
#
# Deploy with `gcloud workflows deploy` (the Terraform module / the
# `hyperframes cloudrun deploy` command do this for you).

main:
  params: [args]
  steps:
    - init:
        assign:
          - serviceUrl: ${args.ServiceUrl}
          - projectGcsUri: ${args.ProjectGcsUri}
          - planOutputGcsPrefix: ${args.PlanOutputGcsPrefix}
          - outputGcsUri: ${args.OutputGcsUri}
          - config: ${args.Config}

    # ── Plan (Activity A) ────────────────────────────────────────────────────
    - plan:
        try:
          call: http.post
          args:
            url: ${serviceUrl}
            timeout: 1800
            auth:
              type: OIDC
            body:
              Action: plan
              ProjectGcsUri: ${projectGcsUri}
              PlanOutputGcsPrefix: ${planOutputGcsPrefix}
              Config: ${config}
          result: planResp
        retry:
          predicate: ${retryable}
          max_retries: 4
          backoff:
            initial_delay: 2
            max_delay: 60
            multiplier: 2
    - capturePlan:
        assign:
          - planResult: ${planResp.body}
          - chunkCount: ${planResult.ChunkCount}

    # ── BuildChunkList + AssertChunkCount ──────────────────────────────────────
    - assertChunkCount:
        switch:
          - condition: ${chunkCount > 0}
            next: buildChunkList
        next: planProducedZeroChunks
    - planProducedZeroChunks:
        raise:
          code: PLAN_PRODUCED_ZERO_CHUNKS
          message: "Plan returned ChunkCount=0 — the composition produced no frames. Non-retryable producer-side invariant violation."
    - buildChunkList:
        # Pre-size the ordered chunk-URI + per-chunk result lists so the
        # parallel branches below assign by index (distinct indices, no
        # read-modify-write race on a shared accumulator).
        assign:
          - chunkIndexes: []
          - chunkUris: []
          - chunkResults: []
    - fillLists:
        for:
          value: i
          range: [0, ${chunkCount - 1}]
          steps:
            - appendSlots:
                assign:
                  - chunkIndexes: ${list.concat(chunkIndexes, i)}
                  - chunkUris: ${list.concat(chunkUris, "")}
                  - chunkResults: ${list.concat(chunkResults, "")}

    # ── RenderChunks (Activity B, fanned out) ──────────────────────────────────
    - renderChunks:
        parallel:
          shared: [chunkUris, chunkResults]
          # Run up to chunkCount chunks at once, clamped to 20 — Cloud
          # Workflows hard-caps concurrent branches/iterations per execution
          # at 20 (https://cloud.google.com/workflows/quotas). Above that,
          # iterations queue regardless of concurrency_limit, so a config with
          # maxParallelChunks > 20 still renders correctly; the extra chunks
          # just wait. All chunkCount iterations always run.
          concurrency_limit: ${math.min(chunkCount, 20)}
          for:
            value: idx
            in: ${chunkIndexes}
            steps:
              - renderOneChunk:
                  try:
                    call: http.post
                    args:
                      url: ${serviceUrl}
                      timeout: 1800
                      auth:
                        type: OIDC
                      body:
                        Action: renderChunk
                        ChunkIndex: ${idx}
                        PlanGcsUri: ${planResult.PlanGcsUri}
                        PlanHash: ${planResult.PlanHash}
                        ChunkOutputGcsPrefix: ${planOutputGcsPrefix}
                        Format: ${planResult.Format}
                    result: chunkResp
                  retry:
                    predicate: ${retryable}
                    max_retries: 4
                    backoff:
                      initial_delay: 2
                      max_delay: 60
                      multiplier: 2
              - storeChunk:
                  assign:
                    - chunkUris[idx]: ${chunkResp.body.ChunkGcsUri}
                    - chunkResults[idx]: ${chunkResp.body}

    # ── Assemble (Activity C) ──────────────────────────────────────────────────
    - assemble:
        try:
          call: http.post
          args:
            url: ${serviceUrl}
            timeout: 1800
            auth:
              type: OIDC
            body:
              Action: assemble
              PlanGcsUri: ${planResult.PlanGcsUri}
              ChunkGcsUris: ${chunkUris}
              AudioGcsUri: ${planResult.AudioGcsUri}
              OutputGcsUri: ${outputGcsUri}
              Format: ${planResult.Format}
              # Forward the caller's exact-CFR request (Config.cfr) to assemble.
              # `"cfr" in config` guards the optional key; when unset this is
              # false, which the handler reads as the default -c copy path.
              Cfr: ${("cfr" in config) and config.cfr}
          result: assembleResp
        retry:
          predicate: ${retryable}
          max_retries: 4
          backoff:
            initial_delay: 2
            max_delay: 60
            multiplier: 2

    - done:
        return:
          Plan: ${planResult}
          Chunks: ${chunkResults}
          Assemble: ${assembleResp.body}

# Retry predicate: retry transient/server failures (429 + 5xx), never the
# handler's non-retryable 400s (bad input, plan-hash mismatch, unsupported
# format, …). Connection / timeout errors carry no `.code`; retry those too.
retryable:
  params: [e]
  steps:
    - classify:
        switch:
          - condition: ${not("code" in e)}
            return: true
          - condition: ${e.code == 429}
            return: true
          - condition: ${e.code >= 500 and e.code < 600}
            return: true
    - nonRetryable:
        return: false
