harn-stdlib 0.10.118

Embedded Harn standard library source catalog
Documentation
/**
 * std/lifecycle/progress — throttled progress reporting for long workflows.
 *
 * A healthy long workflow that prints nothing is indistinguishable from a
 * stuck one, and an operator should not need `ps` and `ls` to tell them apart.
 * This is the shared shape for saying so: a start line, a heartbeat about once
 * a minute, an immediate line whenever the observed state actually changes, and
 * exactly one terminal line.
 *
 * A reporter is a value, not a registered object. Each call returns the next
 * reporter, so parallel stages keep separate identities by construction — two
 * stages simply hold two reporters, and neither can throttle the other.
 *
 * Time comes from `harness.clock.now_ms()`, so `harness.testing.clock_set` and
 * `harness.testing.clock_advance` drive throttling deterministically in tests
 * without a second clock seam.
 *
 * Import:
 *   import { progress_start, progress_tick, progress_finish } from "std/lifecycle/progress"
 */
/** Default heartbeat spacing: often enough to show life, rare enough not to flood a pipe. */
pub const PROGRESS_DEFAULT_INTERVAL_MS = 60000

pub type ProgressOptions = {interval_ms?: int, limit_ms?: int}

pub type ProgressReporter = {
  _type: "progress_reporter",
  stage: string,
  interval_ms: int,
  limit_ms: int?,
  started_ms: int,
  last_emit_ms: int,
  state: string?,
  finished: bool,
  outcome?: string,
}

fn __progress_duration(ms: int) -> string {
  if ms < 1000 {
    return to_string(ms) + "ms"
  }
  const total_seconds = ms / 1000
  const minutes = total_seconds / 60
  const seconds = total_seconds % 60
  if minutes == 0 {
    return to_string(seconds) + "s"
  }
  return to_string(minutes) + "m" + to_string(seconds) + "s"
}

fn __progress_emit(stdio: HarnessStdio, line: string) {
  stdio.log(line)
}

/**
 * progress_start.
 *
 * Announce a stage and return its reporter. `options.interval_ms` overrides the
 * heartbeat spacing; `options.limit_ms` states the time this stage is expected
 * to stay within, which the start line reports so an operator knows when
 * silence has become too long.
 *
 * @effects: [host]
 * @errors: []
 * @api_stability: experimental
 * @example: const p = progress_start(harness, "indexing", {limit_ms: 300000})
 */
pub fn progress_start(
  harness: Harness,
  stage: string,
  options: ProgressOptions? = nil,
) -> ProgressReporter {
  const now = harness.clock.now_ms()
  const interval = options?.interval_ms ?? PROGRESS_DEFAULT_INTERVAL_MS
  const limit = options?.limit_ms
  const name = to_string(stage)
  if limit == nil {
    __progress_emit(harness.stdio, name + ": started")
  } else {
    __progress_emit(harness.stdio, name + ": started (limit " + __progress_duration(limit) + ")")
  }
  return {
    _type: "progress_reporter",
    stage: name,
    interval_ms: interval,
    limit_ms: limit,
    started_ms: now,
    last_emit_ms: now,
    state: nil,
    finished: false,
  }
}

/**
 * progress_tick.
 *
 * Report where the stage is now. A `state` that differs from the last reported
 * one prints immediately — a state change is news, and throttling news is how
 * an operator ends up watching a stale line. Otherwise this prints at most once
 * per `interval_ms` and is silent in between.
 *
 * Returns the next reporter; a finished reporter is returned unchanged.
 *
 * @effects: [host]
 * @errors: []
 * @api_stability: experimental
 * @example: p = progress_tick(harness, p, "412/900 files")
 */
pub fn progress_tick(
  harness: Harness,
  reporter: ProgressReporter,
  state: string? = nil,
) -> ProgressReporter {
  if reporter.finished {
    return reporter
  }
  const now = harness.clock.now_ms()
  const elapsed = now - reporter.started_ms
  const changed = state != nil && state != reporter.state
  const interval = reporter.interval_ms
  const since_last = now - reporter.last_emit_ms
  const due = since_last >= interval
  if !changed && !due {
    return reporter
  }
  const stage = reporter.stage
  const shown = state ?? reporter.state
  if shown == nil {
    __progress_emit(harness.stdio, stage + ": running " + __progress_duration(elapsed))
  } else {
    __progress_emit(
      harness.stdio,
      stage + ": running " + __progress_duration(elapsed) + " - " + to_string(shown),
    )
  }
  return reporter + {last_emit_ms: now, state: shown}
}

/**
 * progress_finish.
 *
 * Close the stage with exactly one terminal line. `outcome` is `"ok"`,
 * `"failed"`, or `"cancelled"`; anything else is reported verbatim so an
 * unexpected outcome is visible rather than silently rounded to success.
 *
 * Calling this twice prints once: a stage that fails inside a `defer` should
 * not have to know whether the error path already closed it.
 *
 * @effects: [host]
 * @errors: []
 * @api_stability: experimental
 * @example: progress_finish(harness, p, "ok")
 */
pub fn progress_finish(
  harness: Harness,
  reporter: ProgressReporter,
  outcome: string,
  detail: string? = nil,
) -> ProgressReporter {
  if reporter.finished {
    return reporter
  }
  const now = harness.clock.now_ms()
  const elapsed = __progress_duration(now - reporter.started_ms)
  const stage = reporter.stage
  const verdict = to_string(outcome)
  let line = if verdict == "ok" {
    stage + ": done in " + elapsed
  } else if verdict == "cancelled" {
    stage + ": cancelled after " + elapsed
  } else if verdict == "failed" {
    stage + ": failed after " + elapsed
  } else {
    stage + ": " + verdict + " after " + elapsed
  }
  if detail != nil {
    line = line + " - " + to_string(detail)
  }
  __progress_emit(harness.stdio, line)
  return reporter + {finished: true, last_emit_ms: now, outcome: verdict}
}