aion-cli 0.6.0

The `aion` command line: operate Aion durable workflows over gRPC and run the Aion server.
//// The dev child workflow: concurrent warm-build + dev start-up,
//// then the bounded scoped verify-fix loop.
////
//// Stage shape (brief sections 2 and 5):
////
//// 1. `workflow.all([warm_build, dev])` — the build cache warms while the
////    dev agent works. The warm build is advisory: its activity reports a
////    failed build as `BuildWarm(ok: False, ..)` data, so a forfeited cache
////    can never fail the run (open question Q4).
//// 2. A bounded verify-fix loop: `scoped_checks` limited to the affected
////    modules; on `CheckFail` the same agent session is resumed with the
////    diagnostics (`dev_resume`) after a durable `workflow.sleep` backoff,
////    and the loop recurses with its attempt counter. Spending the budget
////    surfaces the typed `VerifyFixExhausted` error carrying the last
////    diagnostics — never landed, never infinite.
////
//// The loop cap and backoff are required workflow inputs (open question
//// Q5); this module bakes no defaults. The module is spawned as a child by
//// the parent workflow and is independently dispatchable as its own `[[workflow]]`
//// entry (open question Q6).

import aion/codec
import aion/duration
import aion/query
import aion/workflow
import gleam/dynamic.{type Dynamic}
import gleam/dynamic/decode
import gleam/int
import {{name}}/activities
import {{name}}/codecs_workflows
import {{name}}/errors
import {{name}}/types.{
  type BuildWarm, type DevResult, type DevFlowError, type DevFlowInput,
  type DevFlowResult, CheckFail, CheckPass, CheckResult, DevInput, Developed,
  DevFlowResult, DevFlowStageFailed, DevFlowStatus, ResumeInput, ScopedInput,
  StartupFailed, VerifyFixExhausted, Warmed,
}

/// The child workflow type the parent passes to `workflow.spawn_and_wait`.
/// A deployed workflow type is its entry module name, so this is exactly
/// this module's name.
pub const workflow_type = "{{name}}_dev"

/// Name of the live `{phase, round}` status query this workflow answers.
pub const status_query_name = "{{name}}_dev_status"

/// Typed definition binding the codecs to the execute function.
pub fn definition() -> workflow.WorkflowDefinition(
  DevFlowInput,
  DevFlowResult,
  DevFlowError,
) {
  workflow.define(
    "{{name}}_dev",
    codecs_workflows.dev_flow_input_codec(),
    codecs_workflows.dev_flow_result_codec(),
    codecs_workflows.dev_flow_error_codec(),
    execute,
  )
}

/// Engine entry point for one child execution.
///
/// The runtime delivers the start input as a raw JSON string. Success and
/// failure are both encoded back to JSON text here: the engine records these
/// exact payloads as the child terminal, and the awaiting parent decodes
/// them with the same codecs `{{name}}/codecs_workflows` exports.
pub fn run(raw_input: Dynamic) -> Result(String, String) {
  case decode.run(raw_input, decode.string) {
    Ok(raw_json) ->
      case codecs_workflows.dev_flow_input_codec().decode(raw_json) {
        Ok(input) ->
          case execute(input) {
            Ok(output) ->
              Ok(codecs_workflows.dev_flow_result_codec().encode(output))
            Error(dev_flow_error) ->
              Error(codecs_workflows.dev_flow_error_codec().encode(dev_flow_error))
          }
        Error(codec.DecodeError(reason: reason, path: _)) ->
          Error(
            codecs_workflows.dev_flow_error_codec().encode(DevFlowStageFailed(
              stage: "decode_input",
              message: "failed to decode dev child input: " <> reason,
            )),
          )
      }
    Error(_) ->
      Error(
        codecs_workflows.dev_flow_error_codec().encode(DevFlowStageFailed(
          stage: "decode_input",
          message: "dev child input payload was not a string",
        )),
      )
  }
}

/// Typed workflow body: concurrent start-up, then the bounded verify-fix
/// loop.
pub fn execute(input: DevFlowInput) -> Result(DevFlowResult, DevFlowError) {
  use _ <- result_try(set_status("starting", 0))
  // Brief section 5, "parallel start": warm the build cache while the dev
  // agent works. `workflow.all` collects a homogeneous activity list, so
  // both activities share the StartupTask/StartupResult envelope and the
  // results come back in input order.
  case
    workflow.all([
      activities.warm_build(input.workspace),
      activities.dev(DevInput(
        workspace: input.workspace,
        brief: input.brief,
        design: input.design,
        checklist: input.checklist,
        stories: input.stories,
      )),
    ])
  {
    Ok([Warmed(build_warm: build_warm), Developed(dev_result: dev_result)]) ->
      verify_loop(input, build_warm, dev_result, 1)
    Ok(_) ->
      Error(StartupFailed(
        message: "startup fan-out settled with a result shape other than"
        <> " [warm_build, dev] — envelope contract violation",
      ))
    Error(activity_error) ->
      Error(StartupFailed(message: errors.activity_message(activity_error)))
  }
}

/// One bounded verify-fix round: scoped checks, then on failure a durable
/// backoff, a session resume carrying the diagnostics, and recursion with
/// the attempt counter.
fn verify_loop(
  input: DevFlowInput,
  build_warm: BuildWarm,
  dev_result: DevResult,
  round: Int,
) -> Result(DevFlowResult, DevFlowError) {
  use _ <- result_try(set_status("verifying", round))
  case
    workflow.run(
      activities.scoped_checks(ScopedInput(
        workspace: input.workspace,
        files_touched: dev_result.files_touched,
      )),
    )
  {
    Ok(CheckResult(verdict: CheckPass, affected_modules: _, checked_scope: _)) -> {
      use _ <- result_try(set_status("converged", round))
      Ok(DevFlowResult(
        dev_result: dev_result,
        build_warm: build_warm,
        verify_rounds: round,
      ))
    }
    Ok(CheckResult(
      verdict: CheckFail(diagnostics: diagnostics),
      affected_modules: _,
      checked_scope: _,
    )) ->
      case round >= input.verify_fix_cap {
        True ->
          // Typed exhaustion carrying the last diagnostics: the budget is
          // spent and the run fails loudly instead of looping forever.
          Error(VerifyFixExhausted(rounds: round, diagnostics: diagnostics))
        False -> fix_round(input, build_warm, dev_result, round, diagnostics)
      }
    Error(activity_error) ->
      Error(DevFlowStageFailed(
        stage: "scoped_checks round " <> int.to_string(round),
        message: errors.activity_message(activity_error),
      ))
  }
}

fn fix_round(
  input: DevFlowInput,
  build_warm: BuildWarm,
  dev_result: DevResult,
  round: Int,
  diagnostics: String,
) -> Result(DevFlowResult, DevFlowError) {
  use _ <- result_try(set_status("fixing", round))
  // Durable backoff between rounds — required input, no baked default.
  case workflow.sleep(duration.milliseconds(input.round_backoff_ms)) {
    Ok(Nil) ->
      case
        workflow.run(
          activities.dev_resume(ResumeInput(
            session_id: dev_result.session_id,
            feedback: diagnostics,
          )),
        )
      {
        Ok(resumed) -> verify_loop(input, build_warm, resumed, round + 1)
        Error(activity_error) ->
          Error(DevFlowStageFailed(
            stage: "dev_resume round " <> int.to_string(round),
            message: errors.activity_message(activity_error),
          ))
      }
    Error(engine_error) ->
      Error(DevFlowStageFailed(
        stage: "round_backoff round " <> int.to_string(round),
        message: errors.engine_message(engine_error),
      ))
  }
}

/// Re-register the status handler with the current phase and round, so
/// `{{name}}_dev_status` queries answer live state at every yield point
/// (re-registration per stage, per docs/guides/workflows.md).
fn set_status(phase: String, round: Int) -> Result(Nil, DevFlowError) {
  let status = DevFlowStatus(phase: phase, round: round)
  case
    query.handler(
      status_query_name,
      codecs_workflows.dev_flow_status_codec(),
      fn() { status },
    )
  {
    Ok(Nil) -> Ok(Nil)
    Error(query_error) ->
      Error(DevFlowStageFailed(
        stage: "register_status",
        message: errors.query_message(query_error),
      ))
  }
}

fn result_try(
  result: Result(value, DevFlowError),
  next: fn(value) -> Result(output, DevFlowError),
) -> Result(output, DevFlowError) {
  case result {
    Ok(value) -> next(value)
    Error(dev_flow_error) -> Error(dev_flow_error)
  }
}