///|
fn merge_env(
  base : Map[String, String],
  overlay : Map[String, String],
) -> Map[String, String] {
  let merged : Map[String, String] = {}
  for key, value in base {
    merged[key] = value
  }
  for key, value in overlay {
    merged[key] = value
  }
  merged
}

///|
fn resolve_string(
  workflow_value : String?,
  job_value : String?,
  step_value : String?,
  fallback : String,
) -> String {
  match step_value {
    Some(value) => value
    None =>
      match job_value {
        Some(value) => value
        None =>
          match workflow_value {
            Some(value) => value
            None => fallback
          }
      }
  }
}

///|
fn default_step_id(step : StepSpec, index : Int) -> String {
  if step.id.length() > 0 {
    step.id
  } else {
    "step_" + (index + 1).to_string()
  }
}

///|
fn step_task_id(job_id : String, step_id : String) -> String {
  job_id + "/" + step_id
}

///|
fn finish_task_id(job_id : String) -> String {
  job_id + "/__finish"
}

///|
fn composite_step_id(outer_step_id : String, inner_step_id : String) -> String {
  outer_step_id + "__" + inner_step_id
}

///|
fn lifecycle_step_id(step_id : String, phase : String) -> String {
  step_id + "__" + phase
}

///|
priv struct ExpandedJob {
  job : JobSpec
  visible_needs : Array[String]
  need_targets : Map[String, Array[String]]
  matrix_group : String?
  matrix_fail_fast : Bool
}

///|
fn job_id_set(jobs : Array[JobSpec]) -> Map[String, Bool] {
  let seen : Map[String, Bool] = {}
  for job in jobs {
    seen[job.id] = true
  }
  seen
}

///|
fn workflow_parallelism(job_count : Int) -> Int {
  if job_count == 0 {
    1
  } else {
    job_count
  }
}

///|
fn text_slice(text : String, start : Int, end_ : Int) -> String {
  String::unsafe_substring(text, start~, end=end_)
}

///|
fn normalize_local_action_path(path : String) -> String {
  let trimmed = path.trim(chars=" ").to_owned()
  let is_absolute = trimmed.has_prefix("/")
  let parts : Array[String] = []
  for part_view in trimmed.split("/") {
    let part = part_view.to_owned()
    if part.length() == 0 || part == "." {
      continue
    }
    if part == ".." {
      if parts.length() > 0 && parts[parts.length() - 1] != ".." {
        ignore(parts.pop())
      } else {
        parts.push(part)
      }
      continue
    }
    parts.push(part)
  }
  let normalized = parts.join("/")
  if normalized.length() == 0 {
    if is_absolute {
      "/"
    } else {
      "."
    }
  } else if is_absolute {
    "/" + normalized
  } else {
    normalized
  }
}

///|
fn join_path(base : String, child : String) -> String {
  if child.has_prefix("/") {
    return child
  }
  if base == "." || base.length() == 0 {
    return child
  }
  if base.has_suffix("/") {
    base + child
  } else {
    base + "/" + child
  }
}

///|
fn absolute_lowering_path(path : String) -> String {
  if path.has_prefix("/") {
    return path
  }
  let cwd = @xsys.get_env_var("PWD").unwrap_or(".")
  if cwd.has_suffix("/") {
    cwd + path
  } else {
    cwd + "/" + path
  }
}

///|
fn lowering_path_segments(path : String) -> Array[String] {
  let segments : Array[String] = []
  let absolute = normalize_local_action_path(absolute_lowering_path(path))
  for part_view in absolute.split("/") {
    let part = part_view.to_owned()
    if part.length() > 0 && part != "." {
      segments.push(part)
    }
  }
  segments
}

///|
fn relative_local_action_ref(
  base_root : String,
  target_path : String,
) -> String {
  let base_segments = lowering_path_segments(base_root)
  let target_segments = lowering_path_segments(target_path)
  let mut common = 0
  while common < base_segments.length() &&
        common < target_segments.length() &&
        base_segments[common] == target_segments[common] {
    common += 1
  }
  let relative_segments : Array[String] = []
  for _ in common.. StepSpec {
  match step.uses {
    Some(uses) =>
      match parse_action_ref(uses).action {
        Some(LocalPath(local_path)) => {
          let resolved_path = normalize_local_action_path(
            join_path(source_root, local_path),
          )
          {
            id: step.id,
            name: step.name,
            run: step.run,
            uses: Some(relative_local_action_ref(execution_root, resolved_path)),
            shell: step.shell,
            working_directory: step.working_directory,
            env: step.env,
            with_values: step.with_values,
            if_condition: step.if_condition,
            continue_on_error: step.continue_on_error,
            timeout_minutes: step.timeout_minutes,
          }
        }
        _ => step
      }
    None => step
  }
}

///|
fn github_action_cache_root() -> String {
  absolute_lowering_path(
    @xsys.get_env_var("ACTRUN_GITHUB_ACTION_CACHE_ROOT").unwrap_or(
      "_build/actrun/github_actions",
    ),
  )
}

///|
fn action_registry_root() -> String {
  absolute_lowering_path(
    @xsys.get_env_var("ACTRUN_ACTION_REGISTRY_ROOT").unwrap_or(
      "_build/actrun/registry_actions",
    ),
  )
}

///|
priv struct GitHubActionCacheLayout {
  uses : String
  owner : String
  repo : String
  version : String
  repo_root : String
  action_root : String
}

///|
fn github_action_cache_layout(
  action_ref : ActionRef,
) -> GitHubActionCacheLayout? {
  match action_ref {
    GitHubRepo(owner, repo, version, subpath) =>
      if version.length() == 0 {
        None
      } else {
        let repo_root = join_path(
          join_path(join_path(github_action_cache_root(), owner), repo),
          version,
        )
        let action_root = match subpath {
          Some(path) if path.length() > 0 => join_path(repo_root, path)
          _ => repo_root
        }
        Some({
          uses: action_ref_text(action_ref),
          owner,
          repo,
          version,
          repo_root,
          action_root,
        })
      }
    _ => None
  }
}

///|
fn has_action_manifest(action_root : String) -> Bool {
  read_text(join_path(action_root, "action.yml")) is Some(_) ||
  read_text(join_path(action_root, "action.yaml")) is Some(_)
}

///|
fn cached_github_action_root(action_ref : ActionRef) -> String? {
  match github_action_cache_layout(action_ref) {
    Some(layout) =>
      if has_action_manifest(layout.action_root) {
        Some(layout.action_root)
      } else {
        None
      }
    None => None
  }
}

///|
fn cached_github_repo_path(action_ref : ActionRef) -> String? {
  match github_action_cache_layout(action_ref) {
    Some(layout) =>
      if @xfs.path_exists(layout.action_root) {
        Some(layout.action_root)
      } else {
        None
      }
    None => None
  }
}

///|
fn cached_github_repo_root(action_ref : ActionRef) -> String? {
  match github_action_cache_layout(action_ref) {
    Some(layout) =>
      if @xfs.path_exists(layout.repo_root) {
        Some(layout.repo_root)
      } else {
        None
      }
    None => None
  }
}

///|
fn custom_registry_action_root(action_ref : ActionRef) -> String? {
  match action_ref {
    Registry(scheme, name, version) =>
      if scheme == "builtin" || scheme == "wasm" || version.length() == 0 {
        None
      } else {
        let action_root = join_path(
          join_path(join_path(action_registry_root(), scheme), name),
          version,
        )
        if has_action_manifest(action_root) {
          Some(action_root)
        } else {
          None
        }
      }
    _ => None
  }
}

///|
fn manifest_backed_action_root(action_ref : ActionRef) -> String? {
  match cached_github_action_root(action_ref) {
    Some(root) => Some(root)
    None => custom_registry_action_root(action_ref)
  }
}

///|
fn find_substring(text : String, pattern : String, start : Int) -> Int? {
  if pattern.length() == 0 {
    return Some(start)
  }
  let mut idx = start
  while idx + pattern.length() <= text.length() {
    let mut matched = true
    let mut offset = 0
    while offset < pattern.length() {
      if text.unsafe_get(idx + offset) != pattern.unsafe_get(offset) {
        matched = false
        break
      }
      offset += 1
    }
    if matched {
      return Some(idx)
    }
    idx += 1
  }
  None
}

///|
fn input_expr_name(expression : String) -> String? {
  let trimmed = expression.trim(chars=" \t\n\r").to_owned()
  if trimmed.has_prefix("inputs.") && trimmed.length() > 7 {
    Some(text_slice(trimmed, 7, trimmed.length()))
  } else {
    None
  }
}

///|
fn is_matrix_ident_code(c : UInt16) -> Bool {
  (c >= 'a'.to_int().to_uint16() && c <= 'z'.to_int().to_uint16()) ||
  (c >= 'A'.to_int().to_uint16() && c <= 'Z'.to_int().to_uint16()) ||
  (c >= '0'.to_int().to_uint16() && c <= '9'.to_int().to_uint16()) ||
  c == '_'.to_int().to_uint16() ||
  c == '-'.to_int().to_uint16()
}

///|
fn matrix_expr_name(expression : String) -> String? {
  let trimmed = expression.trim(chars=" \t\n\r").to_owned()
  if trimmed.has_prefix("matrix.") && trimmed.length() > 7 {
    let key = text_slice(trimmed, 7, trimmed.length())
    let mut i = 0
    while i < key.length() {
      if !is_matrix_ident_code(key.unsafe_get(i)) {
        return None
      }
      i += 1
    }
    Some(key)
  } else {
    None
  }
}

///|
fn substitute_matrix_refs_in_expr(
  expression : String,
  matrix_values : Map[String, String],
) -> String {
  let prefix = "matrix."
  let chunks : Array[String] = []
  let mut idx = 0
  while idx < expression.length() {
    match find_substring(expression, prefix, idx) {
      Some(pos) => {
        chunks.push(text_slice(expression, idx, pos))
        let key_start = pos + prefix.length()
        let mut key_end = key_start
        while key_end < expression.length() &&
              is_matrix_ident_code(expression.unsafe_get(key_end)) {
          key_end += 1
        }
        if key_end > key_start {
          let key = text_slice(expression, key_start, key_end)
          let value = matrix_values.get(key).unwrap_or("")
          // Quote the value as a string literal to prevent expression injection.
          // e.g., matrix value "'true' || always()" becomes the safe literal
          // "'\'true\' || always()'" instead of raw injection into the expression.
          chunks.push(quote_expr_string_literal(value))
          idx = key_end
        } else {
          chunks.push(prefix)
          idx = key_start
        }
      }
      None => {
        chunks.push(text_slice(expression, idx, expression.length()))
        idx = expression.length()
      }
    }
  }
  chunks.join("")
}

///|
fn quote_expr_string_literal(value : String) -> String {
  let sq = '\''
  let buf = StringBuilder::new()
  buf.write_char(sq)
  for ch in value {
    if ch == sq {
      buf.write_string("''")
    } else {
      buf.write_char(ch)
    }
  }
  buf.write_char(sq)
  buf.to_string()
}

///|
fn substitute_inputs_text(
  text : String,
  inputs : Map[String, String],
) -> String {
  let chunks : Array[String] = []
  let mut idx = 0
  while idx < text.length() {
    match find_substring(text, "${{", idx) {
      Some(open_idx) => {
        chunks.push(text_slice(text, idx, open_idx))
        match find_substring(text, "}}", open_idx + 3) {
          Some(close_idx) => {
            let expression = text_slice(text, open_idx + 3, close_idx)
            match input_expr_name(expression) {
              Some(name) => chunks.push(inputs.get(name).unwrap_or(""))
              None => chunks.push(text_slice(text, open_idx, close_idx + 2))
            }
            idx = close_idx + 2
          }
          None => {
            chunks.push(text_slice(text, open_idx, text.length()))
            idx = text.length()
          }
        }
      }
      None => {
        chunks.push(text_slice(text, idx, text.length()))
        idx = text.length()
      }
    }
  }
  chunks.join("")
}

///|
fn substitute_matrix_text(
  text : String,
  matrix_values : Map[String, String],
) -> String {
  let chunks : Array[String] = []
  let mut idx = 0
  while idx < text.length() {
    match find_substring(text, "${{", idx) {
      Some(open_idx) => {
        chunks.push(text_slice(text, idx, open_idx))
        match find_substring(text, "}}", open_idx + 3) {
          Some(close_idx) => {
            let expression = text_slice(text, open_idx + 3, close_idx)
            match matrix_expr_name(expression) {
              Some(name) =>
                // Entire expression is matrix.KEY — replace fully
                chunks.push(matrix_values.get(name).unwrap_or(""))
              None => {
                // Check if expression contains matrix.KEY references inline
                let substituted = substitute_matrix_refs_in_expr(
                  expression, matrix_values,
                )
                if substituted != expression {
                  let dollar = "$"
                  chunks.push(dollar + "{{" + substituted + "}}")
                } else {
                  chunks.push(text_slice(text, open_idx, close_idx + 2))
                }
              }
            }
            idx = close_idx + 2
          }
          None => {
            chunks.push(text_slice(text, open_idx, text.length()))
            idx = text.length()
          }
        }
      }
      None => {
        chunks.push(text_slice(text, idx, text.length()))
        idx = text.length()
      }
    }
  }
  chunks.join("")
}

///|
fn substitute_optional_inputs(
  value : String?,
  inputs : Map[String, String],
) -> String? {
  match value {
    Some(text) => Some(substitute_inputs_text(text, inputs))
    None => None
  }
}

///|
fn substitute_optional_matrix(
  value : String?,
  matrix_values : Map[String, String],
) -> String? {
  match value {
    Some(text) => Some(substitute_matrix_text(text, matrix_values))
    None => None
  }
}

///|
fn substitute_inputs_map(
  values : Map[String, String],
  inputs : Map[String, String],
) -> Map[String, String] {
  let result : Map[String, String] = {}
  for key, value in values {
    result[key] = substitute_inputs_text(value, inputs)
  }
  result
}

///|
fn substitute_matrix_map(
  values : Map[String, String],
  matrix_values : Map[String, String],
) -> Map[String, String] {
  let result : Map[String, String] = {}
  for key, value in values {
    result[key] = substitute_matrix_text(value, matrix_values)
  }
  result
}

///|
fn substitute_inputs_list(
  values : Array[String],
  inputs : Map[String, String],
) -> Array[String] {
  let result : Array[String] = []
  for value in values {
    result.push(substitute_inputs_text(value, inputs))
  }
  result
}

///|
fn substitute_matrix_list(
  values : Array[String],
  matrix_values : Map[String, String],
) -> Array[String] {
  let result : Array[String] = []
  for value in values {
    result.push(substitute_matrix_text(value, matrix_values))
  }
  result
}

///|
fn merge_run_defaults(base : RunDefaults, overlay : RunDefaults) -> RunDefaults {
  new_run_defaults(
    shell=match overlay.shell {
      Some(_) => overlay.shell
      None => base.shell
    },
    working_directory=match overlay.working_directory {
      Some(_) => overlay.working_directory
      None => base.working_directory
    },
  )
}

///|
fn reusable_job_id(caller_job_id : String, callee_job_id : String) -> String {
  caller_job_id + "__" + callee_job_id
}

///|
fn terminal_job_ids(jobs : Array[JobSpec]) -> Array[String] {
  let depended : Map[String, Bool] = {}
  for job in jobs {
    for need in job.needs {
      depended[need] = true
    }
  }
  let terminals : Array[String] = []
  for job in jobs {
    if depended.get(job.id) is None {
      terminals.push(job.id)
    }
  }
  terminals
}

///|
fn combine_if_conditions(outer : String, inner : String) -> String {
  if outer == "success()" {
    inner
  } else if inner == "success()" {
    outer
  } else {
    "(" + outer + ") && (" + inner + ")"
  }
}

///|
fn is_reusable_needs_ident_char(ch : UInt16) -> Bool {
  (ch >= 'a'.to_int().to_uint16() && ch <= 'z'.to_int().to_uint16()) ||
  (ch >= 'A'.to_int().to_uint16() && ch <= 'Z'.to_int().to_uint16()) ||
  (ch >= '0'.to_int().to_uint16() && ch <= '9'.to_int().to_uint16()) ||
  ch == '_'.to_int().to_uint16() ||
  ch == '-'.to_int().to_uint16()
}

///|
fn substitute_reusable_needs_text(
  text : String,
  replacements : Map[String, String],
) -> String {
  let chunks : Array[String] = []
  let mut idx = 0
  while idx < text.length() {
    match find_substring(text, "needs.", idx) {
      Some(need_idx) => {
        let start = need_idx + "needs.".length()
        let mut end = start
        while end < text.length() &&
              is_reusable_needs_ident_char(text.unsafe_get(end)) {
          end += 1
        }
        if end == start {
          chunks.push(text_slice(text, idx, need_idx + 1))
          idx = need_idx + 1
          continue
        }
        let name = text_slice(text, start, end)
        match replacements.get(name) {
          Some(replacement) => {
            chunks.push(text_slice(text, idx, need_idx))
            chunks.push("needs." + replacement)
            idx = end
          }
          None => {
            chunks.push(text_slice(text, idx, end))
            idx = end
          }
        }
      }
      None => {
        chunks.push(text_slice(text, idx, text.length()))
        idx = text.length()
      }
    }
  }
  chunks.join("")
}

///|
fn substitute_reusable_needs_optional(
  value : String?,
  replacements : Map[String, String],
) -> String? {
  match value {
    Some(text) => Some(substitute_reusable_needs_text(text, replacements))
    None => None
  }
}

///|
fn substitute_reusable_needs_map(
  values : Map[String, String],
  replacements : Map[String, String],
) -> Map[String, String] {
  let result : Map[String, String] = {}
  for key, value in values {
    result[key] = substitute_reusable_needs_text(value, replacements)
  }
  result
}

///|
fn substitute_reusable_needs_list(
  values : Array[String],
  replacements : Map[String, String],
) -> Array[String] {
  let result : Array[String] = []
  for value in values {
    result.push(substitute_reusable_needs_text(value, replacements))
  }
  result
}

///|
fn secret_expr_name(expression : String) -> String? {
  let trimmed = expression.trim(chars=" \t\n\r").to_owned()
  if trimmed.has_prefix("secrets.") && trimmed.length() > 8 {
    Some(text_slice(trimmed, 8, trimmed.length()))
  } else {
    None
  }
}

///|
fn substitute_secrets_text(
  text : String,
  secrets : Map[String, String],
) -> String {
  let chunks : Array[String] = []
  let mut idx = 0
  while idx < text.length() {
    match find_substring(text, "${{", idx) {
      Some(open_idx) => {
        chunks.push(text_slice(text, idx, open_idx))
        match find_substring(text, "}}", open_idx + 3) {
          Some(close_idx) => {
            let expression = text_slice(text, open_idx + 3, close_idx)
            match secret_expr_name(expression) {
              Some(name) => chunks.push(secrets.get(name).unwrap_or(""))
              None => chunks.push(text_slice(text, open_idx, close_idx + 2))
            }
            idx = close_idx + 2
          }
          None => {
            chunks.push(text_slice(text, open_idx, text.length()))
            idx = text.length()
          }
        }
      }
      None => {
        chunks.push(text_slice(text, idx, text.length()))
        idx = text.length()
      }
    }
  }
  chunks.join("")
}

///|
fn substitute_reusable_bindings_text(
  text : String,
  inputs : Map[String, String],
  secrets : Map[String, String],
) -> String {
  substitute_secrets_text(substitute_inputs_text(text, inputs), secrets)
}

///|
fn substitute_reusable_bindings_optional(
  value : String?,
  inputs : Map[String, String],
  secrets : Map[String, String],
) -> String? {
  match value {
    Some(text) => Some(substitute_reusable_bindings_text(text, inputs, secrets))
    None => None
  }
}

///|
fn substitute_reusable_bindings_map(
  values : Map[String, String],
  inputs : Map[String, String],
  secrets : Map[String, String],
) -> Map[String, String] {
  let result : Map[String, String] = {}
  for key, value in values {
    result[key] = substitute_reusable_bindings_text(value, inputs, secrets)
  }
  result
}

///|
fn substitute_reusable_bindings_list(
  values : Array[String],
  inputs : Map[String, String],
  secrets : Map[String, String],
) -> Array[String] {
  let result : Array[String] = []
  for value in values {
    result.push(substitute_reusable_bindings_text(value, inputs, secrets))
  }
  result
}

///|
fn substitute_reusable_bindings_step(
  step : StepSpec,
  inputs : Map[String, String],
  secrets : Map[String, String],
) -> StepSpec {
  {
    id: step.id,
    name: substitute_reusable_bindings_text(step.name, inputs, secrets),
    run: substitute_reusable_bindings_optional(step.run, inputs, secrets),
    uses: substitute_reusable_bindings_optional(step.uses, inputs, secrets),
    shell: substitute_reusable_bindings_optional(step.shell, inputs, secrets),
    working_directory: substitute_reusable_bindings_optional(
      step.working_directory,
      inputs,
      secrets,
    ),
    env: substitute_reusable_bindings_map(step.env, inputs, secrets),
    with_values: substitute_reusable_bindings_map(
      step.with_values,
      inputs,
      secrets,
    ),
    if_condition: substitute_reusable_bindings_text(
      step.if_condition,
      inputs,
      secrets,
    ),
    continue_on_error: substitute_reusable_bindings_text(
      step.continue_on_error,
      inputs,
      secrets,
    ),
    timeout_minutes: step.timeout_minutes,
  }
}

///|
fn workflow_call_input_implicit_default(input_type : String) -> String {
  match input_type {
    "boolean" => "false"
    "number" => "0"
    _ => ""
  }
}

///|
fn workflow_call_input_is_dynamic(value : String) -> Bool {
  value.contains("${{")
}

///|
fn workflow_call_number_literal_is_valid(value : String) -> Bool {
  if value.length() == 0 {
    return false
  }
  let mut idx = 0
  let mut has_digit = false
  let mut has_dot = false
  if value.unsafe_get(0) == '-'.to_int().to_uint16() ||
    value.unsafe_get(0) == '+'.to_int().to_uint16() {
    idx = 1
  }
  if idx >= value.length() {
    return false
  }
  while idx < value.length() {
    let ch = value.unsafe_get(idx)
    if ch >= '0'.to_int().to_uint16() && ch <= '9'.to_int().to_uint16() {
      has_digit = true
      idx += 1
      continue
    }
    if ch == '.'.to_int().to_uint16() && !has_dot {
      has_dot = true
      idx += 1
      continue
    }
    return false
  }
  has_digit
}

///|
fn workflow_call_input_value_matches_type(
  value : String,
  input_type : String,
) -> Bool {
  if workflow_call_input_is_dynamic(value) {
    return true
  }
  let trimmed = value.trim(chars=" \t\r\n").to_owned()
  match input_type {
    "boolean" => {
      let normalized = trimmed.to_lower()
      normalized == "true" || normalized == "false"
    }
    "number" => workflow_call_number_literal_is_valid(trimmed)
    _ => true
  }
}

///|
fn workflow_job_output_reference(
  text : String,
  replacements : Map[String, String],
) -> String {
  let chunks : Array[String] = []
  let mut idx = 0
  while idx < text.length() {
    match find_substring(text, "jobs.", idx) {
      Some(jobs_idx) => {
        let prefix = "jobs."
        let start = jobs_idx + prefix.length()
        let mut job_end = start
        while job_end < text.length() &&
              is_reusable_needs_ident_char(text.unsafe_get(job_end)) {
          job_end += 1
        }
        if job_end == start {
          chunks.push(text_slice(text, idx, jobs_idx + 1))
          idx = jobs_idx + 1
          continue
        }
        let job_id = text_slice(text, start, job_end)
        let suffix = ".outputs."
        if job_end + suffix.length() > text.length() ||
          text_slice(text, job_end, job_end + suffix.length()) != suffix {
          chunks.push(text_slice(text, idx, job_end))
          idx = job_end
          continue
        }
        let output_start = job_end + suffix.length()
        let mut output_end = output_start
        while output_end < text.length() &&
              is_reusable_needs_ident_char(text.unsafe_get(output_end)) {
          output_end += 1
        }
        if output_end == output_start {
          chunks.push(text_slice(text, idx, output_start))
          idx = output_start
          continue
        }
        chunks.push(text_slice(text, idx, jobs_idx))
        let actual_job = replacements.get(job_id).unwrap_or(job_id)
        chunks.push(
          "needs." +
          actual_job +
          ".outputs." +
          text_slice(text, output_start, output_end),
        )
        idx = output_end
      }
      None => {
        chunks.push(text_slice(text, idx, text.length()))
        idx = text.length()
      }
    }
  }
  chunks.join("")
}

///|
fn substitute_reusable_needs_step(
  step : StepSpec,
  replacements : Map[String, String],
) -> StepSpec {
  {
    id: step.id,
    name: substitute_reusable_needs_text(step.name, replacements),
    run: substitute_reusable_needs_optional(step.run, replacements),
    uses: substitute_reusable_needs_optional(step.uses, replacements),
    shell: substitute_reusable_needs_optional(step.shell, replacements),
    working_directory: substitute_reusable_needs_optional(
      step.working_directory,
      replacements,
    ),
    env: substitute_reusable_needs_map(step.env, replacements),
    with_values: substitute_reusable_needs_map(step.with_values, replacements),
    if_condition: substitute_reusable_needs_text(
      step.if_condition,
      replacements,
    ),
    continue_on_error: substitute_reusable_needs_text(
      step.continue_on_error,
      replacements,
    ),
    timeout_minutes: step.timeout_minutes,
  }
}

///|
fn substitute_matrix_step(
  step : StepSpec,
  matrix_values : Map[String, String],
) -> StepSpec {
  {
    id: step.id,
    name: substitute_matrix_text(step.name, matrix_values),
    run: substitute_optional_matrix(step.run, matrix_values),
    uses: substitute_optional_matrix(step.uses, matrix_values),
    shell: substitute_optional_matrix(step.shell, matrix_values),
    working_directory: substitute_optional_matrix(
      step.working_directory,
      matrix_values,
    ),
    env: substitute_matrix_map(step.env, matrix_values),
    with_values: substitute_matrix_map(step.with_values, matrix_values),
    if_condition: substitute_matrix_text(step.if_condition, matrix_values),
    continue_on_error: substitute_matrix_text(
      step.continue_on_error,
      matrix_values,
    ),
    timeout_minutes: step.timeout_minutes,
  }
}

///|
fn substitute_matrix_job(
  job : JobSpec,
  actual_id : String,
  actual_needs : Array[String],
  matrix_values : Map[String, String],
) -> JobSpec {
  let steps : Array[StepSpec] = []
  for step in job.steps {
    steps.push(substitute_matrix_step(step, matrix_values))
  }
  new_job(
    actual_id,
    steps,
    name=substitute_matrix_text(job.name, matrix_values),
    if_condition=substitute_matrix_text(job.if_condition, matrix_values),
    needs=actual_needs,
    outputs=substitute_matrix_map(job.outputs, matrix_values),
    permissions=job.permissions,
    concurrency=job.concurrency,
    runs_on=substitute_matrix_list(job.runs_on, matrix_values),
    env=substitute_matrix_map(job.env, matrix_values),
    defaults=new_run_defaults(
      shell=substitute_optional_matrix(job.defaults.shell, matrix_values),
      working_directory=substitute_optional_matrix(
        job.defaults.working_directory,
        matrix_values,
      ),
    ),
    matrix=None,
    reusable_workflow=substitute_optional_matrix(
      job.reusable_workflow,
      matrix_values,
    ),
    reusable_workflow_with=substitute_matrix_map(
      job.reusable_workflow_with,
      matrix_values,
    ),
    reusable_workflow_secrets=substitute_matrix_map(
      job.reusable_workflow_secrets,
      matrix_values,
    ),
    reusable_workflow_inherit_secrets=job.reusable_workflow_inherit_secrets,
    services=job.services,
    container=job.container,
    timeout_minutes=job.timeout_minutes,
  )
}

///|
fn matrix_job_id(logical_id : String, index : Int) -> String {
  logical_id + "__matrix_" + (index + 1).to_string()
}

///|
fn expanded_job_ids(jobs : Array[JobSpec]) -> Map[String, Array[String]] {
  let result : Map[String, Array[String]] = {}
  for job in jobs {
    match job.matrix {
      Some(matrix) => {
        let ids : Array[String] = []
        let mut row_index = 0
        while row_index < matrix.rows.length() {
          ids.push(matrix_job_id(job.id, row_index))
          row_index += 1
        }
        result[job.id] = ids
      }
      None => result[job.id] = [job.id]
    }
  }
  result
}

///|
fn resolve_job_targets(
  job_ids : Map[String, Array[String]],
  id : String,
) -> Array[String] {
  match job_ids.get(id) {
    Some(targets) =>
      if targets.length() == 1 && targets[0] == id {
        [id]
      } else {
        let resolved : Array[String] = []
        let seen : Map[String, Bool] = {}
        for target in targets {
          for actual in resolve_job_targets(job_ids, target) {
            if seen.get(actual) is None {
              seen[actual] = true
              resolved.push(actual)
            }
          }
        }
        resolved
      }
    None => [id]
  }
}

///|
fn expand_matrix_jobs(
  jobs : Array[JobSpec],
  aliases? : Map[String, Array[String]] = {},
) -> Array[ExpandedJob] {
  let expanded : Array[ExpandedJob] = []
  let job_ids = expanded_job_ids(jobs)
  for logical_id, targets in aliases {
    job_ids[logical_id] = targets
  }
  for job in jobs {
    match job.matrix {
      Some(matrix) => {
        let limit = matrix.max_parallel.unwrap_or(matrix.rows.length())
        let mut row_index = 0
        for row in matrix.rows {
          let actual_needs : Array[String] = []
          let need_targets : Map[String, Array[String]] = {}
          for need in job.needs {
            let targets = resolve_job_targets(job_ids, need)
            let target_ids : Array[String] = []
            for target in targets {
              actual_needs.push(target)
              target_ids.push(target)
            }
            need_targets[need] = target_ids
          }
          if row_index >= limit {
            actual_needs.push(matrix_job_id(job.id, row_index - limit))
          }
          expanded.push({
            job: substitute_matrix_job(
              job,
              matrix_job_id(job.id, row_index),
              actual_needs,
              row,
            ),
            visible_needs: job.needs,
            need_targets,
            matrix_group: Some(job.id),
            matrix_fail_fast: matrix.fail_fast,
          })
          row_index += 1
        }
      }
      None => {
        let actual_needs : Array[String] = []
        let need_targets : Map[String, Array[String]] = {}
        for need in job.needs {
          let targets = resolve_job_targets(job_ids, need)
          let target_ids : Array[String] = []
          for target in targets {
            actual_needs.push(target)
            target_ids.push(target)
          }
          need_targets[need] = target_ids
        }
        expanded.push({
          job: new_job(
            job.id,
            job.steps,
            name=job.name,
            if_condition=job.if_condition,
            needs=actual_needs,
            outputs=job.outputs,
            permissions=job.permissions,
            concurrency=job.concurrency,
            runs_on=job.runs_on,
            env=job.env,
            defaults=job.defaults,
            matrix=None,
            reusable_workflow=job.reusable_workflow,
            reusable_workflow_with=job.reusable_workflow_with,
            reusable_workflow_secrets=job.reusable_workflow_secrets,
            reusable_workflow_inherit_secrets=job.reusable_workflow_inherit_secrets,
            services=job.services,
            container=job.container,
          ),
          visible_needs: job.needs,
          need_targets,
          matrix_group: None,
          matrix_fail_fast: false,
        })
      }
    }
  }
  expanded
}

///|
fn read_text(path : String) -> String? {
  try @xfs.read_file_to_string(path) catch {
    _ => None
  } noraise {
    content => Some(content)
  }
}

///|
priv struct ReusableWorkflowExpansion {
  jobs : Array[JobSpec]
  aliases : Map[String, Array[String]]
  alias_outputs : Map[String, Map[String, String]]
  alias_output_targets : Map[String, Map[String, Array[String]]]
}

///|
priv struct ReusableWorkflowSource {
  workflow_path : String
  source_root : String
}

///|
fn remote_reusable_workflow_ref(path : String) -> ActionRef? {
  let parsed = parse_action_ref(path)
  guard parsed.action is Some(action_ref) else { return None }
  match action_ref {
    GitHubRepo(_, _, version, Some(subpath)) =>
      if version.length() > 0 && subpath.length() > 0 {
        Some(action_ref)
      } else {
        None
      }
    _ => None
  }
}

///|
fn lowering_leaf_name(path : String) -> String {
  let mut last_slash = -1
  let mut idx = 0
  while idx < path.length() {
    if path.unsafe_get(idx) == '/' {
      last_slash = idx
    }
    idx += 1
  }
  if last_slash < 0 {
    path
  } else {
    exec_text_slice(path, last_slash + 1, path.length())
  }
}

///|
fn reusable_workflow_source(
  path : String,
  workspace_root : String,
) -> ReusableWorkflowSource? {
  if path.has_prefix("./") {
    let normalized = normalize_local_action_path(path)
    let resolved_path = join_path(workspace_root, normalized)
    // Primary: workspace_root + path (GitHub Actions spec)
    if @xfs.path_exists(resolved_path) {
      return Some({ workflow_path: resolved_path, source_root: workspace_root })
    }
    // Fallback: try just the filename in workspace_root
    // Handles cases where workflow is outside .github/workflows/
    // (e.g. examples/ directory with co-located callee)
    let fallback_path = join_path(
      workspace_root,
      lowering_leaf_name(normalized),
    )
    if @xfs.path_exists(fallback_path) {
      return Some({ workflow_path: fallback_path, source_root: workspace_root })
    }
    // Return primary path (will fail at read_text with clear error)
    return Some({ workflow_path: resolved_path, source_root: workspace_root })
  }
  guard remote_reusable_workflow_ref(path) is Some(action_ref) else {
    return None
  }
  guard cached_github_repo_path(action_ref) is Some(workflow_path) else {
    return None
  }
  Some({
    workflow_path,
    source_root: cached_github_repo_root(action_ref).unwrap_or(workspace_root),
  })
}

///|
fn expanded_row_jobs_for_reusable_caller(job : JobSpec) -> Array[JobSpec] {
  let rows : Array[JobSpec] = []
  for expanded in expand_matrix_jobs([job]) {
    rows.push(expanded.job)
  }
  rows
}

///|
fn expand_reusable_workflows_in_workspace(
  jobs : Array[JobSpec],
  workspace_root : String,
  execution_root : String,
  errors : Array[String],
) -> ReusableWorkflowExpansion {
  let expanded : Array[JobSpec] = []
  let aliases : Map[String, Array[String]] = {}
  let alias_outputs : Map[String, Map[String, String]] = {}
  let alias_output_targets : Map[String, Map[String, Array[String]]] = {}
  for job in jobs {
    match job.reusable_workflow {
      Some(path) => {
        let before_errors = errors.length()
        if workspace_root.length() == 0 {
          errors.push(
            "job '\{job.id}' reusable workflow '\{path}' requires workspace-aware lowering",
          )
          continue
        }
        if job.steps.length() > 0 {
          errors.push(
            "job '\{job.id}' reusable workflow caller steps are not supported in MVP",
          )
        }
        if job.outputs.length() > 0 {
          errors.push(
            "job '\{job.id}' reusable workflow caller outputs are not supported in MVP",
          )
        }
        // permissions and concurrency silently ignored for local execution
        ignore(job.permissions)
        ignore(job.concurrency)
        if job.container_image is Some(image) {
          errors.push(
            "job '\{job.id}' reusable workflow container is not supported in MVP: \{image}",
          )
        }
        if errors.length() > before_errors {
          continue
        }
        match reusable_workflow_source(path, workspace_root) {
          Some(source) =>
            match read_text(source.workflow_path) {
              Some(text) => {
                let parsed = parse_workflow_yaml(text)
                for err in parsed.errors {
                  errors.push(
                    "job '\{job.id}' reusable workflow '\{path}' parse error: " +
                    err,
                  )
                }
                guard parsed.workflow is Some(callee) else { continue }
                if !callee.workflow_call {
                  errors.push(
                    "job '\{job.id}' reusable workflow '\{path}' must use on: workflow_call",
                  )
                  continue
                }
                let workflow_call = callee.workflow_call_spec.unwrap_or(
                  new_workflow_call_spec(),
                )
                let bound_inputs : Map[String, String] = {}
                for input_name, input_spec in workflow_call.inputs {
                  let bound_value = match
                    job.reusable_workflow_with.get(input_name) {
                    Some(value) => Some(value)
                    None =>
                      match input_spec.default_value {
                        Some(value) => Some(value)
                        None =>
                          if input_spec.required {
                            errors.push(
                              "job '\{job.id}' reusable workflow missing required input '\{input_name}': \{path}",
                            )
                            None
                          } else {
                            Some(
                              workflow_call_input_implicit_default(
                                input_spec.input_type,
                              ),
                            )
                          }
                      }
                  }
                  match bound_value {
                    Some(value) =>
                      if workflow_call_input_value_matches_type(
                          value,
                          input_spec.input_type,
                        ) {
                        bound_inputs[input_name] = value
                      } else {
                        errors.push(
                          "job '\{job.id}' reusable workflow input '\{input_name}' expects \{input_spec.input_type} but got '\{value}': \{path}",
                        )
                      }
                    None => ()
                  }
                }
                for input_name, _ in job.reusable_workflow_with {
                  if workflow_call.inputs.get(input_name) is None {
                    errors.push(
                      "job '\{job.id}' reusable workflow unknown input '\{input_name}': \{path}",
                    )
                  }
                }
                let bound_secrets : Map[String, String] = {}
                for secret_name, secret_spec in workflow_call.secrets {
                  if job.reusable_workflow_inherit_secrets {
                    let dollar = "$"
                    bound_secrets[secret_name] = dollar +
                      "{{ secrets." +
                      secret_name +
                      " }}"
                  }
                  match job.reusable_workflow_secrets.get(secret_name) {
                    Some(value) => bound_secrets[secret_name] = value
                    None =>
                      if secret_spec.required &&
                        bound_secrets.get(secret_name) is None {
                        errors.push(
                          "job '\{job.id}' reusable workflow missing required secret '\{secret_name}': \{path}",
                        )
                      }
                  }
                }
                for secret_name, _ in job.reusable_workflow_secrets {
                  if workflow_call.secrets.get(secret_name) is None {
                    errors.push(
                      "job '\{job.id}' reusable workflow unknown secret '\{secret_name}': \{path}",
                    )
                  }
                }
                // permissions and concurrency silently ignored
                ignore(callee.permissions)
                ignore(callee.concurrency)
                let callee_ids : Map[String, String] = {}
                for callee_job in callee.jobs {
                  callee_ids[callee_job.id] = reusable_job_id(
                    job.id,
                    callee_job.id,
                  )
                }
                if errors.length() > before_errors {
                  continue
                }
                match job.matrix {
                  Some(_) => {
                    let row_ids : Array[String] = []
                    for row_job in expanded_row_jobs_for_reusable_caller(job) {
                      row_ids.push(row_job.id)
                      let nested = expand_reusable_workflows_in_workspace(
                        [row_job],
                        workspace_root,
                        execution_root,
                        errors,
                      )
                      if errors.length() > before_errors {
                        break
                      }
                      for nested_job in nested.jobs {
                        expanded.push(nested_job)
                      }
                      for alias_id, targets in nested.aliases {
                        aliases[alias_id] = targets
                      }
                      for alias_id, outputs in nested.alias_outputs {
                        alias_outputs[alias_id] = outputs
                      }
                      for alias_id, targets in nested.alias_output_targets {
                        alias_output_targets[alias_id] = targets
                      }
                    }
                    if errors.length() > before_errors {
                      continue
                    }
                    aliases[job.id] = row_ids
                    if workflow_call.outputs.length() > 0 {
                      let workflow_output_targets : Map[String, Array[String]] = {}
                      for output_name, _ in workflow_call.outputs {
                        workflow_output_targets[output_name] = row_ids
                      }
                      alias_output_targets[job.id] = workflow_output_targets
                    }
                    continue
                  }
                  None => ()
                }
                let transformed_jobs : Array[JobSpec] = []
                for callee_job in callee.jobs {
                  let steps : Array[StepSpec] = []
                  for step in callee_job.steps {
                    let substituted_step = rewrite_reusable_step_local_uses(
                      substitute_reusable_needs_step(
                        substitute_reusable_bindings_step(
                          step, bound_inputs, bound_secrets,
                        ),
                        callee_ids,
                      ),
                      source.source_root,
                      execution_root,
                    )
                    steps.push(substituted_step)
                  }
                  let actual_needs : Array[String] = []
                  if callee_job.needs.length() == 0 {
                    for need in job.needs {
                      actual_needs.push(need)
                    }
                  } else {
                    for need in callee_job.needs {
                      actual_needs.push(callee_ids.get(need).unwrap_or(need))
                    }
                  }
                  transformed_jobs.push(
                    new_job(
                      callee_ids.get(callee_job.id).unwrap_or(callee_job.id),
                      steps,
                      name=substitute_reusable_bindings_text(
                        callee_job.name,
                        bound_inputs,
                        bound_secrets,
                      ),
                      if_condition=combine_if_conditions(
                        job.if_condition,
                        substitute_reusable_needs_text(
                          substitute_reusable_bindings_text(
                            callee_job.if_condition,
                            bound_inputs,
                            bound_secrets,
                          ),
                          callee_ids,
                        ),
                      ),
                      needs=actual_needs,
                      outputs=substitute_reusable_needs_map(
                        substitute_reusable_bindings_map(
                          callee_job.outputs,
                          bound_inputs,
                          bound_secrets,
                        ),
                        callee_ids,
                      ),
                      permissions=callee_job.permissions,
                      concurrency=callee_job.concurrency,
                      runs_on=substitute_reusable_needs_list(
                        substitute_reusable_bindings_list(
                          callee_job.runs_on,
                          bound_inputs,
                          bound_secrets,
                        ),
                        callee_ids,
                      ),
                      env=substitute_reusable_needs_map(
                        substitute_reusable_bindings_map(
                          merge_env(callee.env, callee_job.env),
                          bound_inputs,
                          bound_secrets,
                        ),
                        callee_ids,
                      ),
                      defaults=merge_run_defaults(
                        new_run_defaults(
                          shell=substitute_reusable_bindings_optional(
                            callee.defaults.shell,
                            bound_inputs,
                            bound_secrets,
                          ),
                          working_directory=substitute_reusable_bindings_optional(
                            callee.defaults.working_directory,
                            bound_inputs,
                            bound_secrets,
                          ),
                        ),
                        new_run_defaults(
                          shell=substitute_reusable_needs_optional(
                            substitute_reusable_bindings_optional(
                              callee_job.defaults.shell,
                              bound_inputs,
                              bound_secrets,
                            ),
                            callee_ids,
                          ),
                          working_directory=substitute_reusable_needs_optional(
                            substitute_reusable_bindings_optional(
                              callee_job.defaults.working_directory,
                              bound_inputs,
                              bound_secrets,
                            ),
                            callee_ids,
                          ),
                        ),
                      ),
                      matrix=callee_job.matrix,
                      reusable_workflow=substitute_reusable_bindings_optional(
                        callee_job.reusable_workflow,
                        bound_inputs,
                        bound_secrets,
                      ),
                      reusable_workflow_with=substitute_reusable_bindings_map(
                        callee_job.reusable_workflow_with,
                        bound_inputs,
                        bound_secrets,
                      ),
                      reusable_workflow_secrets=substitute_reusable_bindings_map(
                        callee_job.reusable_workflow_secrets,
                        bound_inputs,
                        bound_secrets,
                      ),
                      reusable_workflow_inherit_secrets=callee_job.reusable_workflow_inherit_secrets,
                      services=callee_job.services,
                      container=callee_job.container,
                    ),
                  )
                }
                if errors.length() > before_errors {
                  continue
                }
                let nested = expand_reusable_workflows_in_workspace(
                  transformed_jobs,
                  source.source_root,
                  execution_root,
                  errors,
                )
                if errors.length() > before_errors {
                  continue
                }
                for nested_job in nested.jobs {
                  expanded.push(nested_job)
                }
                for alias_id, targets in nested.aliases {
                  aliases[alias_id] = targets
                }
                for alias_id, outputs in nested.alias_outputs {
                  alias_outputs[alias_id] = outputs
                }
                for alias_id, targets in nested.alias_output_targets {
                  alias_output_targets[alias_id] = targets
                }
                let terminals : Array[String] = []
                let seen_terminals : Map[String, Bool] = {}
                for terminal_id in terminal_job_ids(callee.jobs) {
                  let namespaced = callee_ids
                    .get(terminal_id)
                    .unwrap_or(terminal_id)
                  let targets = nested.aliases
                    .get(namespaced)
                    .unwrap_or([namespaced])
                  for target in targets {
                    if seen_terminals.get(target) is None {
                      seen_terminals[target] = true
                      terminals.push(target)
                    }
                  }
                }
                aliases[job.id] = terminals
                let workflow_outputs : Map[String, String] = {}
                for output_name, output_spec in workflow_call.outputs {
                  workflow_outputs[output_name] = workflow_job_output_reference(
                    substitute_reusable_bindings_text(
                      output_spec.value,
                      bound_inputs,
                      bound_secrets,
                    ),
                    callee_ids,
                  )
                }
                alias_outputs[job.id] = workflow_outputs
              }
              None =>
                errors.push(
                  "job '\{job.id}' reusable workflow '\{path}' is missing or not cached",
                )
            }
          None =>
            errors.push(
              "job '\{job.id}' reusable workflow is not supported in MVP: \{path}",
            )
        }
      }
      None => expanded.push(job)
    }
  }
  { jobs: expanded, aliases, alias_outputs, alias_output_targets }
}

///|
fn task_needs(job : JobSpec, previous_task_id : String) -> Array[String] {
  let needs : Array[String] = []
  if previous_task_id.length() > 0 {
    needs.push(previous_task_id)
  } else {
    for need in job.needs {
      needs.push(finish_task_id(need))
    }
  }
  needs
}

///|
fn step_name(step : StepSpec, resolved_step_id : String) -> String {
  if step.name.length() > 0 {
    step.name
  } else {
    resolved_step_id
  }
}

///|
fn validate_step_if(
  job_id : String,
  step_id : String,
  if_condition : String,
  errors : Array[String],
) -> Unit {
  if !if_condition_supported(if_condition) {
    errors.push(
      "step '\{job_id}/\{step_id}' if='\{if_condition}' is not supported in MVP",
    )
  }
}

///|
fn validate_job_if(
  job_id : String,
  if_condition : String,
  errors : Array[String],
) -> Unit {
  if !if_condition_supported(if_condition) {
    errors.push("job '\{job_id}' if='\{if_condition}' is not supported in MVP")
  }
}

///|
fn validate_continue_on_error(
  job_id : String,
  step_id : String,
  continue_on_error : String,
  errors : Array[String],
) -> Unit {
  if !if_condition_supported(continue_on_error) {
    errors.push(
      "step '\{job_id}/\{step_id}' continue-on-error='\{continue_on_error}' is not supported in MVP",
    )
  }
}

///|
fn append_run_task(
  tasks : Array[@wf.FlowTask],
  task_plans : Array[TaskPlan],
  job : JobSpec,
  task_id : String,
  step_id : String,
  name : String,
  script : String,
  needs : Array[String],
  shell : String,
  working_directory : String,
  if_condition : String,
  continue_on_error : String,
  env : Map[String, String],
  with_values? : Map[String, String] = {},
  action_scope? : String? = None,
  timeout_minutes? : Int = 0,
) -> Unit {
  tasks.push(
    @wf.new_task(task_id, job.id, script, needs, env~, cwd=working_directory),
  )
  task_plans.push(
    new_task_plan(
      task_id,
      "run",
      job.id,
      step_id,
      name,
      script,
      shell,
      working_directory,
      if_condition,
      job.runs_on,
      env,
      with_values~,
      continue_on_error~,
      action_scope~,
      timeout_minutes~,
    ),
  )
}

///|
fn append_action_task(
  tasks : Array[@wf.FlowTask],
  task_plans : Array[TaskPlan],
  job : JobSpec,
  task_id : String,
  step_id : String,
  name : String,
  needs : Array[String],
  shell : String,
  working_directory : String,
  if_condition : String,
  continue_on_error : String,
  env : Map[String, String],
  action : ResolvedAction,
  with_values? : Map[String, String] = {},
  action_scope? : String? = None,
  requires_action_started? : Bool = false,
  timeout_minutes? : Int = 0,
) -> Unit {
  tasks.push(
    @wf.new_task(task_id, job.id, "__actrun_" + action.kind + "__", needs, env~),
  )
  task_plans.push(
    new_task_plan(
      task_id,
      "action",
      job.id,
      step_id,
      name,
      "",
      shell,
      working_directory,
      if_condition,
      job.runs_on,
      env,
      with_values~,
      continue_on_error~,
      action=Some(action),
      action_scope~,
      requires_action_started~,
      timeout_minutes~,
    ),
  )
}

///|
priv struct DeferredActionTask {
  task_id : String
  step_id : String
  name : String
  shell : String
  working_directory : String
  if_condition : String
  continue_on_error : String
  env : Map[String, String]
  with_values : Map[String, String]
  action : ResolvedAction
  action_scope : String?
  requires_action_started : Bool
}

///|
fn append_deferred_action_tasks(
  tasks : Array[@wf.FlowTask],
  task_plans : Array[TaskPlan],
  job : JobSpec,
  deferred_tasks : Array[DeferredActionTask],
  previous_task_id : String,
) -> String {
  let mut next_previous_task_id = previous_task_id
  let mut idx = deferred_tasks.length()
  while idx > 0 {
    idx -= 1
    let deferred = deferred_tasks[idx]
    append_action_task(
      tasks,
      task_plans,
      job,
      deferred.task_id,
      deferred.step_id,
      deferred.name,
      task_needs(job, next_previous_task_id),
      deferred.shell,
      deferred.working_directory,
      deferred.if_condition,
      deferred.continue_on_error,
      deferred.env,
      deferred.action,
      with_values=deferred.with_values,
      action_scope=deferred.action_scope,
      requires_action_started=deferred.requires_action_started,
    )
    next_previous_task_id = deferred.task_id
  }
  next_previous_task_id
}

///|
fn action_input_env_name(name : String) -> String {
  // @actions/core getInput replaces spaces with _ but keeps hyphens
  "INPUT_" + name.replace_all(old=" ", new="_").to_upper()
}

///|
fn action_input_env_name_underscore(name : String) -> String {
  // Actrun builtin actions use underscore form (INPUT_FETCH_DEPTH)
  "INPUT_" +
  name.replace_all(old="-", new="_").replace_all(old=" ", new="_").to_upper()
}

///|
fn action_input_env(
  defaults : Map[String, String],
  overrides : Map[String, String],
) -> Map[String, String] {
  let env : Map[String, String] = {}
  for key, value in merge_env(defaults, overrides) {
    // Resolve unresolved ${{ }} expressions in action.yml defaults
    // github.token is resolved later in executor; keep it as-is
    // Boolean-like expressions (== comparisons) default to "false"
    // Other unresolved expressions default to empty string
    let resolved_value = if value.contains("${{") {
      if value.contains("github.token") {
        value
      } else if value.contains("==") {
        "false"
      } else {
        ""
      }
    } else {
      value
    }
    let hyphen_name = action_input_env_name(key)
    env[hyphen_name] = resolved_value
    // Also set underscore form for builtin action compatibility
    let underscore_name = action_input_env_name_underscore(key)
    if hyphen_name != underscore_name {
      env[underscore_name] = resolved_value
    }
  }
  env
}

///|
fn github_action_context_env(action_ref : ActionRef) -> Map[String, String] {
  let env : Map[String, String] = {}
  match action_ref {
    GitHubRepo(owner, repo, version, _) => {
      env["GITHUB_ACTION_REPOSITORY"] = owner + "/" + repo
      if version.length() > 0 {
        env["GITHUB_ACTION_REF"] = version
      }
    }
    _ => ()
  }
  env
}

///|
fn lower_builtin_action(
  job : JobSpec,
  step : StepSpec,
  resolved_step_id : String,
  previous_task_id : String,
  shell : String,
  working_directory : String,
  env : Map[String, String],
  action_ref : ActionRef,
  action : ResolvedAction,
  tasks : Array[@wf.FlowTask],
  task_plans : Array[TaskPlan],
  deferred_post_tasks : Array[DeferredActionTask],
) -> String {
  let task_id = step_task_id(job.id, resolved_step_id)
  let needs = task_needs(job, previous_task_id)
  let setup_node_cache = step.with_values
    .get("cache")
    .unwrap_or("")
    .trim(chars=" \t\r\n")
    .length() >
    0
  if action.kind == "cache" || (action.kind == "setup-node" && setup_node_cache) {
    let action_scope = task_id
    append_action_task(
      tasks,
      task_plans,
      job,
      task_id,
      resolved_step_id,
      step_name(step, resolved_step_id),
      needs,
      shell,
      working_directory,
      step.if_condition,
      step.continue_on_error,
      env,
      action,
      with_values=step.with_values,
      action_scope=Some(action_scope),
    )
    deferred_post_tasks.push({
      task_id: step_task_id(job.id, lifecycle_step_id(resolved_step_id, "post")),
      step_id: lifecycle_step_id(resolved_step_id, "post"),
      name: step_name(step, resolved_step_id) + " / post",
      shell,
      working_directory,
      if_condition: "success()",
      continue_on_error: "false",
      env,
      with_values: step.with_values,
      action: {
        uses: action.uses,
        action_ref,
        kind: if action.kind == "setup-node" {
          "setup-node-cache-post"
        } else {
          "cache-save-post"
        },
        backend: "builtin",
        capabilities: backend_capabilities_for("builtin"),
        action_path: None,
        entrypoint: None,
        image: None,
        args: [],
      },
      action_scope: Some(action_scope),
      requires_action_started: true,
    })
    return task_id
  }
  append_action_task(
    tasks,
    task_plans,
    job,
    task_id,
    resolved_step_id,
    step_name(step, resolved_step_id),
    needs,
    shell,
    working_directory,
    step.if_condition,
    step.continue_on_error,
    env,
    action,
    with_values=step.with_values,
  )
  task_id
}

///|
fn lower_manifest_backed_action(
  workflow : WorkflowSpec,
  job : JobSpec,
  base_env : Map[String, String],
  step : StepSpec,
  resolved_step_id : String,
  previous_task_id : String,
  action_ref : ActionRef,
  cached_root : String,
  workspace_root : String,
  tasks : Array[@wf.FlowTask],
  task_plans : Array[TaskPlan],
  deferred_post_tasks : Array[DeferredActionTask],
  errors : Array[String],
  composite_output_mappings? : Map[String, Map[String, String]] = {},
) -> String {
  guard find_local_action_manifest(workspace_root, cached_root)
    is Some((_, manifest_text)) else {
    errors.push(
      "step '\{job.id}/\{resolved_step_id}' action '\{action_ref_text(action_ref)}' is missing action.yml or action.yaml",
    )
    return previous_task_id
  }
  let parsed_manifest = parse_action_manifest_yaml(manifest_text)
  guard parsed_manifest.action is Some(action_manifest) else {
    for err in parsed_manifest.errors {
      errors.push(
        "step '\{job.id}/\{resolved_step_id}' action '\{action_ref_text(action_ref)}' " +
        err,
      )
    }
    return previous_task_id
  }
  let action_context_env = github_action_context_env(action_ref)
  if action_manifest.runtime == "composite" {
    return lower_local_action(
      workflow,
      job,
      base_env,
      step,
      resolved_step_id,
      previous_task_id,
      cached_root,
      workspace_root,
      tasks,
      task_plans,
      deferred_post_tasks,
      errors,
      action_context_env~,
      composite_output_mappings~,
    )
  }
  if action_manifest.runtime.has_prefix("node") {
    let action_scope = step_task_id(job.id, resolved_step_id)
    let task_id = step_task_id(job.id, resolved_step_id)
    let shell = resolve_string(
      workflow.defaults.shell,
      job.defaults.shell,
      step.shell,
      "bash",
    )
    let working_directory = resolve_string(
      workflow.defaults.working_directory,
      job.defaults.working_directory,
      step.working_directory,
      "",
    )
    let step_env = merge_env(base_env, step.env)
    let node_env = merge_env(
      merge_env(
        step_env,
        action_input_env(action_manifest.inputs, step.with_values),
      ),
      merge_env(action_context_env, { "GITHUB_ACTION_PATH": cached_root }),
    )
    let action_name = step_name(step, resolved_step_id)
    let pre_if_condition = if action_manifest.pre_if == "always()" {
      step.if_condition
    } else {
      "success()"
    }
    let mut local_previous_task_id = previous_task_id
    match action_manifest.pre {
      Some(pre) if pre.length() > 0 => {
        let pre_step_id = lifecycle_step_id(resolved_step_id, "pre")
        let pre_task_id = step_task_id(job.id, pre_step_id)
        append_action_task(
          tasks,
          task_plans,
          job,
          pre_task_id,
          pre_step_id,
          action_name + " / pre",
          task_needs(job, local_previous_task_id),
          shell,
          working_directory,
          pre_if_condition,
          "false",
          node_env,
          {
            uses: action_ref_text(action_ref),
            action_ref,
            kind: "node-script",
            backend: "node",
            capabilities: backend_capabilities_for("node"),
            action_path: Some(cached_root),
            entrypoint: Some(join_path(cached_root, pre)),
            image: None,
            args: [],
          },
          with_values=step.with_values,
          action_scope=Some(action_scope),
        )
        local_previous_task_id = pre_task_id
      }
      _ => ()
    }
    append_action_task(
      tasks,
      task_plans,
      job,
      task_id,
      resolved_step_id,
      step_name(step, resolved_step_id),
      task_needs(job, local_previous_task_id),
      shell,
      working_directory,
      step.if_condition,
      step.continue_on_error,
      node_env,
      {
        uses: action_ref_text(action_ref),
        action_ref,
        kind: "node-script",
        backend: "node",
        capabilities: backend_capabilities_for("node"),
        action_path: Some(cached_root),
        entrypoint: Some(join_path(cached_root, action_manifest.main)),
        image: None,
        args: [],
      },
      with_values=step.with_values,
      action_scope=Some(action_scope),
    )
    match action_manifest.post {
      Some(post) if post.length() > 0 =>
        deferred_post_tasks.push({
          task_id: step_task_id(
            job.id,
            lifecycle_step_id(resolved_step_id, "post"),
          ),
          step_id: lifecycle_step_id(resolved_step_id, "post"),
          name: action_name + " / post",
          shell,
          working_directory,
          if_condition: action_manifest.post_if,
          continue_on_error: "false",
          env: node_env,
          with_values: step.with_values,
          action: {
            uses: action_ref_text(action_ref),
            action_ref,
            kind: "node-script",
            backend: "node",
            capabilities: backend_capabilities_for("node"),
            action_path: Some(cached_root),
            entrypoint: Some(join_path(cached_root, post)),
            image: None,
            args: [],
          },
          action_scope: Some(action_scope),
          requires_action_started: true,
        })
      _ => ()
    }
    return task_id
  }
  if action_manifest.runtime == "docker" {
    let action_scope = step_task_id(job.id, resolved_step_id)
    let task_id = step_task_id(job.id, resolved_step_id)
    let shell = resolve_string(
      workflow.defaults.shell,
      job.defaults.shell,
      step.shell,
      "bash",
    )
    let working_directory = resolve_string(
      workflow.defaults.working_directory,
      job.defaults.working_directory,
      step.working_directory,
      "",
    )
    let resolved_inputs = merge_env(action_manifest.inputs, step.with_values)
    let step_env = merge_env(base_env, step.env)
    let docker_env = merge_env(
      step_env,
      merge_env(
        action_input_env(action_manifest.inputs, step.with_values),
        action_context_env,
      ),
    )
    let args = substitute_inputs_list(action_manifest.args, resolved_inputs)
    let resolved_image = substitute_inputs_text(
      action_manifest.image,
      resolved_inputs,
    )
    let image = if resolved_image.has_prefix("docker://") {
      text_slice(resolved_image, 9, resolved_image.length())
    } else {
      join_path(cached_root, resolved_image)
    }
    let action_name = step_name(step, resolved_step_id)
    let pre_if_condition = if action_manifest.pre_if == "always()" {
      step.if_condition
    } else {
      "success()"
    }
    let mut local_previous_task_id = previous_task_id
    match
      substitute_optional_inputs(
        action_manifest.pre_entrypoint,
        resolved_inputs,
      ) {
      Some(pre_entrypoint) if pre_entrypoint.length() > 0 => {
        let pre_step_id = lifecycle_step_id(resolved_step_id, "pre")
        let pre_task_id = step_task_id(job.id, pre_step_id)
        append_action_task(
          tasks,
          task_plans,
          job,
          pre_task_id,
          pre_step_id,
          action_name + " / pre",
          task_needs(job, local_previous_task_id),
          shell,
          working_directory,
          pre_if_condition,
          "false",
          docker_env,
          {
            uses: action_ref_text(action_ref),
            action_ref,
            kind: "docker-action",
            backend: "docker",
            capabilities: backend_capabilities_for("docker"),
            action_path: Some(cached_root),
            entrypoint: Some(pre_entrypoint),
            image: Some(image),
            args,
          },
          with_values=step.with_values,
          action_scope=Some(action_scope),
        )
        local_previous_task_id = pre_task_id
      }
      _ => ()
    }
    append_action_task(
      tasks,
      task_plans,
      job,
      task_id,
      resolved_step_id,
      step_name(step, resolved_step_id),
      task_needs(job, local_previous_task_id),
      shell,
      working_directory,
      step.if_condition,
      step.continue_on_error,
      docker_env,
      {
        uses: action_ref_text(action_ref),
        action_ref,
        kind: "docker-action",
        backend: "docker",
        capabilities: backend_capabilities_for("docker"),
        action_path: Some(cached_root),
        entrypoint: substitute_optional_inputs(
          action_manifest.entrypoint,
          resolved_inputs,
        ),
        image: Some(image),
        args,
      },
      with_values=step.with_values,
      action_scope=Some(action_scope),
    )
    match
      substitute_optional_inputs(
        action_manifest.post_entrypoint,
        resolved_inputs,
      ) {
      Some(post_entrypoint) if post_entrypoint.length() > 0 =>
        deferred_post_tasks.push({
          task_id: step_task_id(
            job.id,
            lifecycle_step_id(resolved_step_id, "post"),
          ),
          step_id: lifecycle_step_id(resolved_step_id, "post"),
          name: action_name + " / post",
          shell,
          working_directory,
          if_condition: action_manifest.post_if,
          continue_on_error: "false",
          env: docker_env,
          with_values: step.with_values,
          action: {
            uses: action_ref_text(action_ref),
            action_ref,
            kind: "docker-action",
            backend: "docker",
            capabilities: backend_capabilities_for("docker"),
            action_path: Some(cached_root),
            entrypoint: Some(post_entrypoint),
            image: Some(image),
            args,
          },
          action_scope: Some(action_scope),
          requires_action_started: true,
        })
      _ => ()
    }
    return task_id
  }
  errors.push(
    "step '\{job.id}/\{resolved_step_id}' action '\{action_ref_text(action_ref)}' using='\{action_manifest.runtime}' is not supported in MVP",
  )
  previous_task_id
}

///|
fn is_path_traversal(path : String) -> Bool {
  let normalized = normalize_local_action_path(path)
  normalized.has_prefix("..") || normalized.has_prefix("/")
}

///|
fn find_local_action_manifest(
  workspace_root : String,
  action_path : String,
) -> (String, String)? {
  let action_root = join_path(
    workspace_root,
    normalize_local_action_path(action_path),
  )
  let yml_path = join_path(action_root, "action.yml")
  match read_text(yml_path) {
    Some(text) => Some((yml_path, text))
    None => {
      let yaml_path = join_path(action_root, "action.yaml")
      match read_text(yaml_path) {
        Some(text) => Some((yaml_path, text))
        None => None
      }
    }
  }
}

///|
fn lower_local_action(
  workflow : WorkflowSpec,
  job : JobSpec,
  job_env : Map[String, String],
  outer_step : StepSpec,
  outer_step_id : String,
  previous_task_id : String,
  action_path : String,
  workspace_root : String,
  tasks : Array[@wf.FlowTask],
  task_plans : Array[TaskPlan],
  deferred_post_tasks : Array[DeferredActionTask],
  errors : Array[String],
  action_context_env? : Map[String, String] = {},
  composite_output_mappings? : Map[String, Map[String, String]] = {},
) -> String {
  if workspace_root.length() == 0 {
    errors.push(
      "step '\{job.id}/\{outer_step_id}' local action '\{action_path}' requires workspace-aware lowering",
    )
    return previous_task_id
  }
  guard find_local_action_manifest(workspace_root, action_path)
    is Some((manifest_path, manifest_text)) else {
    errors.push(
      "step '\{job.id}/\{outer_step_id}' local action '\{action_path}' is missing action.yml or action.yaml",
    )
    return previous_task_id
  }
  // Parse as full manifest to support composite, node, and docker local actions
  let parsed_manifest = parse_action_manifest_yaml(manifest_text)
  guard parsed_manifest.action is Some(action_manifest) else {
    for err in parsed_manifest.errors {
      errors.push(
        "step '\{job.id}/\{outer_step_id}' local action '\{manifest_path}' " +
        err,
      )
    }
    return previous_task_id
  }
  // For node/docker local actions, delegate to manifest-backed lowering
  if action_manifest.runtime != "composite" {
    let local_action_ref = LocalPath(action_path)
    let action_path_root = normalize_local_action_path(action_path)
    let abs_action_root = absolute_exec_path(
      join_path(workspace_root, action_path_root),
    )
    return lower_manifest_backed_action(
      workflow,
      job,
      job_env,
      outer_step,
      outer_step_id,
      previous_task_id,
      local_action_ref,
      abs_action_root,
      workspace_root,
      tasks,
      task_plans,
      deferred_post_tasks,
      errors,
      composite_output_mappings~,
    )
  }
  let action = new_local_action(
    action_manifest.steps,
    name=action_manifest.name,
    inputs=action_manifest.inputs,
    outputs=action_manifest.outputs,
  )

  let mut local_previous_task_id = previous_task_id
  let resolved_inputs = merge_env(action.inputs, outer_step.with_values)
  let action_path_root = normalize_local_action_path(action_path)
  let action_root = join_path(workspace_root, action_path_root)
  let action_scope = step_task_id(job.id, outer_step_id)
  let outer_env = merge_env(job_env, outer_step.env)
  let outer_name = step_name(outer_step, outer_step_id)
  let mut inner_index = 0
  while inner_index < action.steps.length() {
    let inner_step = action.steps[inner_index]
    let inner_step_id = default_step_id(inner_step, inner_index)
    let resolved_step_id = composite_step_id(outer_step_id, inner_step_id)
    validate_step_if(job.id, resolved_step_id, inner_step.if_condition, errors)
    validate_continue_on_error(
      job.id,
      resolved_step_id,
      inner_step.continue_on_error,
      errors,
    )
    let resolved_name = if inner_step.name.length() > 0 {
      outer_name +
      " / " +
      substitute_inputs_text(inner_step.name, resolved_inputs)
    } else {
      outer_name + " / " + inner_step_id
    }
    if inner_step.uses is Some(uses) {
      let parsed_uses = parse_action_ref(uses)
      guard parsed_uses.action is Some(action_ref) else {
        for err in parsed_uses.errors {
          errors.push("step '\{job.id}/\{resolved_step_id}' " + err)
        }
        inner_index += 1
        continue
      }
      let nested_env = substitute_inputs_map(inner_step.env, resolved_inputs)
      let nested_with_values = substitute_inputs_map(
        inner_step.with_values,
        resolved_inputs,
      )
      let nested_outer_step : StepSpec = {
        id: inner_step.id,
        name: substitute_inputs_text(inner_step.name, resolved_inputs),
        run: None,
        uses: inner_step.uses,
        shell: None,
        working_directory: None,
        env: nested_env,
        with_values: nested_with_values,
        if_condition: inner_step.if_condition,
        continue_on_error: inner_step.continue_on_error,
        timeout_minutes: inner_step.timeout_minutes,
      }
      match action_ref {
        LocalPath(path) => {
          let nested_action_path = normalize_local_action_path(
            join_path(action_path_root, path),
          )
          local_previous_task_id = lower_local_action(
            workflow,
            job,
            outer_env,
            nested_outer_step,
            resolved_step_id,
            local_previous_task_id,
            nested_action_path,
            workspace_root,
            tasks,
            task_plans,
            deferred_post_tasks,
            errors,
            action_context_env~,
            composite_output_mappings~,
          )
          inner_index += 1
          continue
        }
        _ => ()
      }
      let resolved_action = resolve_action_ref(action_ref)
      if resolved_action.action is Some(action) {
        let task_id = step_task_id(job.id, resolved_step_id)
        let needs = task_needs(job, local_previous_task_id)
        append_action_task(
          tasks,
          task_plans,
          job,
          task_id,
          resolved_step_id,
          resolved_name,
          needs,
          resolve_string(
            workflow.defaults.shell,
            job.defaults.shell,
            None,
            "bash",
          ),
          resolve_string(
            workflow.defaults.working_directory,
            job.defaults.working_directory,
            None,
            "",
          ),
          inner_step.if_condition,
          inner_step.continue_on_error,
          merge_env(
            merge_env(
              merge_env(outer_env, nested_env),
              action_input_env({}, nested_with_values),
            ),
            action_context_env,
          ),
          action,
          with_values=nested_with_values,
        )
        local_previous_task_id = task_id
        inner_index += 1
        continue
      }
      if manifest_backed_action_root(action_ref) is Some(cached_root) {
        local_previous_task_id = lower_manifest_backed_action(
          workflow,
          job,
          outer_env,
          nested_outer_step,
          resolved_step_id,
          local_previous_task_id,
          action_ref,
          cached_root,
          workspace_root,
          tasks,
          task_plans,
          deferred_post_tasks,
          errors,
          composite_output_mappings~,
        )
        inner_index += 1
        continue
      }
      guard resolved_action.action is Some(_) else {
        for err in resolved_action.errors {
          errors.push("step '\{job.id}/\{resolved_step_id}' " + err)
        }
        inner_index += 1
        continue
      }
    }
    guard inner_step.run is Some(script) else {
      errors.push("step '\{job.id}/\{resolved_step_id}' must define run")
      inner_index += 1
      continue
    }
    let task_id = step_task_id(job.id, resolved_step_id)
    let substituted_shell = substitute_optional_inputs(
      inner_step.shell,
      resolved_inputs,
    )
    let shell = resolve_string(
      workflow.defaults.shell,
      job.defaults.shell,
      substituted_shell,
      "bash",
    )
    let substituted_working_directory = substitute_optional_inputs(
      inner_step.working_directory,
      resolved_inputs,
    )
    let working_directory = resolve_string(
      workflow.defaults.working_directory,
      job.defaults.working_directory,
      substituted_working_directory,
      "",
    )
    let env = merge_env(
      substitute_inputs_map(
        merge_env(outer_env, inner_step.env),
        resolved_inputs,
      ),
      merge_env(action_context_env, { "GITHUB_ACTION_PATH": action_root }),
    )
    let substituted_script = substitute_inputs_text(script, resolved_inputs)
    let needs = task_needs(job, local_previous_task_id)
    append_run_task(
      tasks,
      task_plans,
      job,
      task_id,
      resolved_step_id,
      resolved_name,
      substituted_script,
      needs,
      shell,
      working_directory,
      inner_step.if_condition,
      inner_step.continue_on_error,
      env,
      action_scope=Some(action_scope),
    )
    local_previous_task_id = task_id
    inner_index += 1
  }
  // Register composite action output mappings
  // outputs map: output_name → expression (e.g., "${{ steps.write.outputs.value }}")
  // We resolve inner step references relative to the outer step scope
  if action.outputs.length() > 0 {
    let resolved_outputs : Map[String, String] = {}
    for output_name, expression in action.outputs {
      // Rewrite inner step references to composite step IDs
      // e.g., "steps.write.outputs.value" → look up "outer__write" in step_outputs
      resolved_outputs[output_name] = resolve_composite_output_expression(
        expression, outer_step_id,
      )
    }
    composite_output_mappings[outer_step_id] = resolved_outputs
  }
  local_previous_task_id
}

///|
fn resolve_composite_output_expression(
  expression : String,
  outer_step_id : String,
) -> String {
  // Parse "${{ steps..outputs. }}" and return "outer__inner:key"
  // as a compact representation the executor can resolve
  let trimmed = expression.trim(chars=" \t\n\r").to_owned()
  let prefix = "${{"
  let suffix = "}}"
  guard trimmed.has_prefix(prefix) && trimmed.has_suffix(suffix) else {
    return expression
  }
  let inner = text_slice(
      trimmed,
      prefix.length(),
      trimmed.length() - suffix.length(),
    )
    .trim(chars=" \t\n\r")
    .to_owned()
  let steps_prefix = "steps."
  guard inner.has_prefix(steps_prefix) else { return expression }
  let rest = text_slice(inner, steps_prefix.length(), inner.length())
  let outputs_marker = ".outputs."
  guard find_substring(rest, outputs_marker, 0) is Some(dot_idx) else {
    return expression
  }
  let inner_step_id = text_slice(rest, 0, dot_idx)
  let output_key = text_slice(
    rest,
    dot_idx + outputs_marker.length(),
    rest.length(),
  )
  let composite_id = composite_step_id(outer_step_id, inner_step_id)
  composite_id + ":" + output_key
}

///|
fn is_wasi_job(job : JobSpec) -> Bool {
  for runner in job.runs_on {
    if runner == "wasi" {
      return true
    }
  }
  false
}

///|
fn validate_wasi_job(job : JobSpec, errors : Array[String]) -> Unit {
  if job.container is Some(_) {
    errors.push(
      "job '\{job.id}' uses runs-on: wasi but has container: which is not supported in wasi mode",
    )
  }
  if job.services.length() > 0 {
    errors.push(
      "job '\{job.id}' uses runs-on: wasi but has services: which is not supported in wasi mode",
    )
  }
  let mut step_index = 0
  while step_index < job.steps.length() {
    let step = job.steps[step_index]
    let step_id = default_step_id(step, step_index)
    match step.uses {
      Some(uses) => {
        let parsed = parse_action_ref(uses)
        match parsed.action {
          Some(Registry("wasm", _, _)) => () // wasm:// actions are allowed
          Some(_) =>
            errors.push(
              "job '\{job.id}' step '\{step_id}' uses '\{uses}' which is not a wasm:// action; only wasm:// actions are allowed in wasi mode",
            )
          None => () // parse error handled elsewhere
        }
      }
      None =>
        if step.run is Some(_) {
          errors.push(
            "job '\{job.id}' step '\{step_id}' uses run: which is not supported in wasi mode; use a wasm:// action instead",
          )
        }
    }
    step_index += 1
  }
}

///|
pub fn lower_push_workflow_in_workspace(
  workflow : WorkflowSpec,
  workspace_root : String,
) -> LoweringResult {
  let errors : Array[String] = []
  let nodes : Array[@wf.FlowNode] = []
  let tasks : Array[@wf.FlowTask] = []
  let task_plans : Array[TaskPlan] = []
  let job_outputs : Map[String, Map[String, String]] = {}
  let job_if_conditions : Map[String, String] = {}
  let job_needs : Map[String, Array[String]] = {}
  let job_need_targets : Map[String, Map[String, Array[String]]] = {}
  let job_virtual_targets : Map[String, Array[String]] = {}
  let job_virtual_output_targets : Map[String, Map[String, Array[String]]] = {}
  let job_matrix_groups : Map[String, String] = {}
  let job_matrix_fail_fast : Map[String, Bool] = {}
  let job_containers : Map[String, JobContainerSpec] = {}
  let job_services : Map[String, Map[String, JobContainerSpec]] = {}
  let composite_output_mappings : Map[String, Map[String, String]] = {}
  let entry_targets : Array[String] = []
  let seen_jobs : Map[String, Bool] = {}
  if workflow.workflow_call {
    errors.push("workflow_call is not supported in MVP")
  }
  let reusable_expansion = expand_reusable_workflows_in_workspace(
    workflow.jobs,
    workspace_root,
    workspace_root,
    errors,
  )
  let expanded_jobs = expand_matrix_jobs(
    reusable_expansion.jobs,
    aliases=reusable_expansion.aliases,
  )
  for job_id, targets in reusable_expansion.aliases {
    job_virtual_targets[job_id] = targets
  }
  for job_id, outputs in reusable_expansion.alias_outputs {
    job_outputs[job_id] = outputs
  }
  for job_id, output_targets in reusable_expansion.alias_output_targets {
    job_virtual_output_targets[job_id] = output_targets
  }
  // permissions and concurrency are silently ignored for local execution
  ignore(workflow.permissions)
  ignore(workflow.concurrency)
  let expanded_specs : Array[JobSpec] = []
  for expanded in expanded_jobs {
    expanded_specs.push(expanded.job)
  }
  let known_jobs = job_id_set(expanded_specs)

  for expanded in expanded_jobs {
    let job = expanded.job
    if job.id.length() == 0 {
      errors.push("job id is empty")
      continue
    }
    if seen_jobs.get(job.id) is Some(_) {
      errors.push("duplicate job id '\{job.id}'")
      continue
    }
    seen_jobs[job.id] = true
    nodes.push(@wf.new_node(job.id, job.needs))
  }

  for expanded in expanded_jobs {
    let job = expanded.job
    job_outputs[job.id] = job.outputs
    job_if_conditions[job.id] = job.if_condition
    job_needs[job.id] = expanded.visible_needs
    job_need_targets[job.id] = expanded.need_targets
    match expanded.matrix_group {
      Some(group) => {
        job_matrix_groups[job.id] = group
        job_matrix_fail_fast[job.id] = expanded.matrix_fail_fast
      }
      None => ()
    }
    match job.container {
      Some(container) => job_containers[job.id] = container
      None => ()
    }
    if job.services.length() > 0 {
      job_services[job.id] = job.services
    }
    if job.reusable_workflow is Some(path) {
      errors.push(
        "job '\{job.id}' reusable workflow is not supported in MVP: \{path}",
      )
    }
    // permissions and concurrency are silently ignored for local execution
    ignore(job.permissions)
    ignore(job.concurrency)
    validate_job_if(job.id, job.if_condition, errors)
    for need in job.needs {
      if known_jobs.get(need) is None {
        errors.push("job '\{job.id}' needs unknown job '\{need}'")
      }
    }
    if job.steps.length() == 0 {
      errors.push("job '\{job.id}' must have at least one step")
    }
    if is_wasi_job(job) {
      validate_wasi_job(job, errors)
    }

    let mut previous_task_id = ""
    let deferred_post_tasks : Array[DeferredActionTask] = []
    let job_env = merge_env(workflow.env, job.env)
    let mut step_index = 0
    while step_index < job.steps.length() {
      let step = job.steps[step_index]
      let resolved_step_id = default_step_id(step, step_index)
      validate_step_if(job.id, resolved_step_id, step.if_condition, errors)
      validate_continue_on_error(
        job.id,
        resolved_step_id,
        step.continue_on_error,
        errors,
      )
      let shell = resolve_string(
        workflow.defaults.shell,
        job.defaults.shell,
        step.shell,
        "bash",
      )
      let working_directory = resolve_string(
        workflow.defaults.working_directory,
        job.defaults.working_directory,
        step.working_directory,
        "",
      )
      let step_env = merge_env(job_env, step.env)

      if step.uses is Some(uses) {
        let parsed = parse_action_ref(uses)
        guard parsed.action is Some(action_ref) else {
          for err in parsed.errors {
            errors.push("step '\{job.id}/\{resolved_step_id}' " + err)
          }
          step_index += 1
          continue
        }
        match action_ref {
          LocalPath(path) => {
            // Only check user-authored local action refs (./path).
            // Paths from reusable workflow expansion may legitimately
            // contain ".." to reach cached repos.
            if path.has_prefix("./") && is_path_traversal(path) {
              errors.push(
                "step '\{job.id}/\{resolved_step_id}' local action path '\{path}' escapes workspace",
              )
              step_index += 1
              continue
            }
            previous_task_id = lower_local_action(
              workflow,
              job,
              job_env,
              step,
              resolved_step_id,
              previous_task_id,
              path,
              workspace_root,
              tasks,
              task_plans,
              deferred_post_tasks,
              errors,
              composite_output_mappings~,
            )
            step_index += 1
            continue
          }
          _ => ()
        }
        let resolved = resolve_action_ref(action_ref)
        if resolved.action is Some(action) {
          previous_task_id = lower_builtin_action(
            job,
            step,
            resolved_step_id,
            previous_task_id,
            shell,
            working_directory,
            merge_env(
              merge_env(step_env, action_input_env({}, step.with_values)),
              github_action_context_env(action_ref),
            ),
            action_ref,
            action,
            tasks,
            task_plans,
            deferred_post_tasks,
          )
          step_index += 1
          continue
        }
        if workspace_root.length() > 0 &&
          manifest_backed_action_root(action_ref) is Some(cached_root) {
          previous_task_id = lower_manifest_backed_action(
            workflow,
            job,
            job_env,
            step,
            resolved_step_id,
            previous_task_id,
            action_ref,
            cached_root,
            workspace_root,
            tasks,
            task_plans,
            deferred_post_tasks,
            errors,
            composite_output_mappings~,
          )
          step_index += 1
          continue
        }
        guard resolved.action is Some(_) else {
          for err in resolved.errors {
            errors.push("step '\{job.id}/\{resolved_step_id}' " + err)
          }
          step_index += 1
          continue
        }
      }
      guard step.run is Some(script) else {
        errors.push("step '\{job.id}/\{resolved_step_id}' must define run")
        step_index += 1
        continue
      }
      let task_id = step_task_id(job.id, resolved_step_id)
      let needs = task_needs(job, previous_task_id)
      let effective_timeout = if step.timeout_minutes > 0 {
        step.timeout_minutes
      } else {
        job.timeout_minutes
      }
      append_run_task(
        tasks,
        task_plans,
        job,
        task_id,
        resolved_step_id,
        step_name(step, resolved_step_id),
        script,
        needs,
        shell,
        working_directory,
        step.if_condition,
        step.continue_on_error,
        step_env,
        with_values=step.with_values,
        timeout_minutes=effective_timeout,
      )
      previous_task_id = task_id
      step_index += 1
    }

    previous_task_id = append_deferred_action_tasks(
      tasks, task_plans, job, deferred_post_tasks, previous_task_id,
    )

    let barrier_id = finish_task_id(job.id)
    let barrier_needs = task_needs(job, previous_task_id)
    tasks.push(
      @wf.new_task(barrier_id, job.id, "__actrun_barrier__", barrier_needs),
    )
    task_plans.push(
      new_task_plan(
        barrier_id,
        "barrier",
        job.id,
        "__finish",
        "job finish",
        "",
        "",
        "",
        "success()",
        job.runs_on,
        {},
      ),
    )
    entry_targets.push(barrier_id)
  }

  let ir = @wf.new_ir(
    workflow.name,
    nodes,
    tasks,
    entry_targets~,
    max_parallel=workflow_parallelism(expanded_jobs.length()),
  )
  for issue in @wf.ir_issues(ir) {
    errors.push(issue)
  }
  {
    ir,
    plan: {
      tasks: task_plans,
      job_outputs,
      job_if_conditions,
      job_needs,
      job_need_targets,
      job_virtual_targets,
      job_virtual_output_targets,
      job_matrix_groups,
      job_matrix_fail_fast,
      job_containers,
      job_services,
      composite_output_mappings,
    },
    errors,
  }
}

///|
pub fn lower_push_workflow(workflow : WorkflowSpec) -> LoweringResult {
  lower_push_workflow_in_workspace(workflow, "")
}