run method

Future<WorkspaceHookOutcome> run({
  1. required WorkspaceHookKind kind,
  2. required String workspacePath,
  3. required HooksWorkflowConfig hooks,
})

Runs kind for workspacePath using config in hooks.

When the hook is unset for kind, returns a successful outcome with empty output. When the hook fails or times out and kind is fatal, the caller is expected to throw WorkspaceException with the proper code.

Implementation

Future<WorkspaceHookOutcome> run({
  required WorkspaceHookKind kind,
  required String workspacePath,
  required HooksWorkflowConfig hooks,
}) async {
  final script = _scriptFor(kind, hooks);
  if (script == null || script.trim().isEmpty) {
    return WorkspaceHookOutcome(
      kind: kind,
      succeeded: true,
      timedOut: false,
      exitCode: 0,
      output: '',
    );
  }

  logger.detail('[hook ${kind.keyName}] starting in $workspacePath');

  final invocation = _resolveShellInvocation(hooks, script);

  final Process process;
  try {
    process = await Process.start(
      invocation.executable,
      invocation.arguments,
      workingDirectory: workspacePath,
      runInShell: false,
    );
  } catch (e) {
    logger.err('[hook ${kind.keyName}] failed to spawn: $e');
    return WorkspaceHookOutcome(
      kind: kind,
      succeeded: false,
      timedOut: false,
      exitCode: null,
      output: 'failed to spawn: $e',
    );
  }

  // Bound the captured output at write time so a runaway hook script that
  // floods stdout/stderr cannot OOM the orchestrator. The streams are still
  // drained from the OS pipe buffer (so the child doesn't block on a full
  // pipe), the data past `maxOutputBytes` is just dropped on the floor.
  final outputBuffer = StringBuffer();
  var truncated = false;
  void appendBounded(String chunk) {
    if (outputBuffer.length >= maxOutputBytes) {
      truncated = true;
      return;
    }
    final remaining = maxOutputBytes - outputBuffer.length;
    if (chunk.length <= remaining) {
      outputBuffer.write(chunk);
    } else {
      outputBuffer.write(chunk.substring(0, remaining));
      truncated = true;
    }
  }

  // Drain both streams to completion rather than cancelling them when the
  // exit code arrives: `process.exitCode` can complete before buffered
  // output has been delivered, and cancelling at that point silently drops
  // the tail of the hook's output (observed on fast Linux CI runners).
  final stdoutDone = process.stdout
      .transform(utf8.decoder)
      .forEach(appendBounded);
  final stderrDone = process.stderr
      .transform(utf8.decoder)
      .forEach(appendBounded);

  int? exitCode;
  var timedOut = false;
  exitCode = await process.exitCode.timeout(
    hooks.timeout,
    onTimeout: () {
      timedOut = true;
      process.kill(ProcessSignal.sigterm);
      return -1;
    },
  );

  // The streams close once the process is gone; the short timeout guards
  // against a SIGTERM-ignoring child keeping the pipes open forever.
  try {
    await Future.wait([
      stdoutDone,
      stderrDone,
    ]).timeout(const Duration(seconds: 5));
  } catch (_) {
    // Decode errors or straggling pipes: keep whatever was captured.
  }

  final captured = truncated
      ? '${outputBuffer.toString()}...[truncated]'
      : outputBuffer.toString();
  final succeeded = !timedOut && exitCode == 0;

  if (timedOut) {
    logger.err(
      '[hook ${kind.keyName}] timed out after ${hooks.timeout.inSeconds}s',
    );
  } else if (!succeeded) {
    logger.err('[hook ${kind.keyName}] exited with code $exitCode');
  } else {
    logger.detail('[hook ${kind.keyName}] completed successfully');
  }

  return WorkspaceHookOutcome(
    kind: kind,
    succeeded: succeeded,
    timedOut: timedOut,
    exitCode: timedOut ? null : exitCode,
    output: captured,
  );
}