@workflow-manager/runner
Version:
CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes
317 lines (316 loc) • 11.6 kB
JavaScript
// RunObserver implementation for `wfm run --ui`: owns the mutable TUI state
// (latest snapshot, per-step logs, selection/follow, transient status
// messages), wires TerminalScreen input/output, and composes+paints frames
// via the pure renderFrame(). This is the only piece that talks timers,
// process streams, and the session approve/resume/cancel controls — layout
// math and terminal I/O stay in layout.ts / screen.ts.
import { isTerminalStatus } from "../runFormat.js";
import { renderFrame } from "./layout.js";
import { TuiLogStore } from "./logStore.js";
import { TerminalScreen } from "./screen.js";
import { createTheme } from "./theme.js";
import picocolors from "picocolors";
const DEFAULT_REDRAW_MS = 80;
const DEFAULT_TICK_MS = 1000;
const STATUS_MESSAGE_TTL_MS = 4000;
const STEP_TERMINAL_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
function readString(payload, key) {
const value = payload[key];
return typeof value === "string" ? value : undefined;
}
function buildEventMetaText(event) {
const payload = event.payload;
if (event.type === "agent.started") {
const command = readString(payload, "command") ?? "agent";
const args = Array.isArray(payload.args) ? payload.args.map((value) => String(value)).join(" ") : "";
const model = readString(payload, "model");
return `agent started: ${command}${args ? ` ${args}` : ""}${model ? ` model=${model}` : ""}`;
}
if (event.type === "agent.finished") {
const status = readString(payload, "executionStatus") ?? "unknown";
const exitStatus = typeof payload.exitStatus === "number" || payload.exitStatus === null ? ` exit=${String(payload.exitStatus)}` : "";
const timedOut = payload.timedOut === true ? " timedOut=true" : "";
return `agent finished: ${status}${exitStatus}${timedOut}`;
}
if (event.type === "step.execution_finished") {
const status = readString(payload, "status") ?? "unknown";
const action = readString(payload, "action");
const feedbackReason = readString(payload, "feedbackReason");
return `execution finished: ${status}${action ? ` action=${action}` : ""}${feedbackReason ? ` reason=${feedbackReason}` : ""}`;
}
if (event.type === "step.validation_started") {
const adapter = readString(payload, "adapter");
const criteria = readString(payload, "criteria");
return `agent validation started${adapter ? ` (${adapter})` : ""}${criteria ? ` criteria=${criteria}` : ""}`;
}
if (event.type === "step.validation_finished") {
const status = readString(payload, "status") ?? "unknown";
const action = readString(payload, "action");
const feedbackReason = readString(payload, "feedbackReason");
return `agent validation: ${status}${action ? ` action=${action}` : ""}${feedbackReason ? ` reason=${feedbackReason}` : ""}`;
}
if (event.type === "step.retried") {
const attempt = typeof payload.attempt === "number" ? payload.attempt : undefined;
return attempt !== undefined ? `retry scheduled (attempt ${attempt})` : "retry scheduled";
}
if (event.type === "approval.resolved") {
const decision = readString(payload, "decision") ?? "unknown";
const actor = readString(payload, "actor");
return `approval resolved: ${decision}${actor ? ` by ${actor}` : ""}`;
}
return null;
}
export class TuiRunRenderer {
workflow;
session;
attachUrl;
attachToken;
stdout;
stdin;
redrawMs;
tickMs;
nowFn;
theme;
screen;
snapshot = null;
stepDetails = new Map();
logs = new TuiLogStore();
selectedStepKey = null;
follow = true;
statusMessage = null;
statusMessageExpiresAt = null;
dirty = true;
redrawTimer = null;
tickTimer = null;
started = false;
stopped = false;
constructor(options) {
this.workflow = options.workflow;
this.session = options.session;
this.attachUrl = options.attachUrl;
this.attachToken = options.attachToken;
this.stdout = options.stdout ?? process.stdout;
this.stdin = options.stdin ?? process.stdin;
this.redrawMs = options.redrawMs ?? DEFAULT_REDRAW_MS;
this.tickMs = options.tickMs ?? DEFAULT_TICK_MS;
this.nowFn = options.now ?? Date.now;
this.theme = createTheme(options.colorEnabled ?? picocolors.isColorSupported);
this.screen = new TerminalScreen({
stdout: this.stdout,
stdin: this.stdin,
onKey: this.handleKey,
onResize: this.handleResize,
});
}
start() {
if (this.started) {
return;
}
this.started = true;
this.stopped = false;
this.screen.start();
if (this.redrawMs > 0) {
this.redrawTimer = setInterval(() => {
if (this.dirty) {
this.renderNow();
}
}, this.redrawMs);
}
if (this.tickMs > 0) {
this.tickTimer = setInterval(() => {
const status = this.snapshot?.status;
if (status === "running" || status === "waiting_for_approval") {
this.markDirty();
}
}, this.tickMs);
}
// Seed an initial (pending / snapshot-null) frame so the alt screen never
// shows a blank body while waiting on the first onSnapshot.
this.renderNow();
}
stop() {
if (this.stopped) {
return;
}
this.stopped = true;
this.started = false;
if (this.redrawTimer) {
clearInterval(this.redrawTimer);
this.redrawTimer = null;
}
if (this.tickTimer) {
clearInterval(this.tickTimer);
this.tickTimer = null;
}
this.screen.stop();
}
onSnapshot(snapshot, stepDetails) {
this.snapshot = snapshot;
this.stepDetails = new Map(stepDetails.map((detail) => [detail.stepKey, detail]));
if (this.follow) {
const firstNonTerminal = snapshot.steps.find((step) => !STEP_TERMINAL_STATUSES.has(step.status))?.stepKey;
this.selectedStepKey = snapshot.currentStepKey ?? firstNonTerminal ?? this.selectedStepKey;
}
if (isTerminalStatus(snapshot.status)) {
this.logs.flushPartialLines();
}
this.markDirty();
}
onEvent(event) {
const text = buildEventMetaText(event);
if (text === null) {
return;
}
this.logs.appendMeta(event.stepRunId, text);
this.markDirty();
}
onLog(log) {
this.logs.appendChunk(log);
this.markDirty();
}
handleKey = (key) => {
const name = key.name;
if (name === "up" || name === "k") {
this.follow = false;
this.moveSelection(-1);
this.markDirty();
return;
}
if (name === "down" || name === "j") {
this.follow = false;
this.moveSelection(1);
this.markDirty();
return;
}
if (name === "f") {
this.follow = true;
this.selectedStepKey = this.snapshot?.currentStepKey ?? this.selectedStepKey;
this.markDirty();
return;
}
if (name === "q" || (key.ctrl && name === "c")) {
this.handleQuit();
return;
}
if (name === "a") {
this.handleApprove();
return;
}
if (name === "r") {
this.handleResume();
return;
}
if (name === "c") {
this.handleCancel();
return;
}
this.markDirty();
};
renderNow() {
const rows = renderFrame(this.composeFrame());
this.screen.paint(rows);
this.dirty = false;
return rows;
}
handleResize = () => {
this.markDirty();
};
handleApprove() {
const waiting = this.snapshot?.waitingForApproval;
if (!waiting) {
this.markDirty();
return;
}
if (waiting.validation === "external") {
this.setStatusMessage("external step — press r to resume");
this.markDirty();
return;
}
const result = this.session.approve(waiting.stepKey, { actor: "cli", source: "tui" });
if (!result.ok) {
this.setStatusMessage(`approve failed: ${result.reason ?? "unknown error"}`);
}
this.markDirty();
}
handleResume() {
const waiting = this.snapshot?.waitingForApproval;
if (!waiting) {
this.markDirty();
return;
}
const result = this.session.resume(waiting.stepKey, { actor: "cli", source: "tui" });
if (!result.ok) {
this.setStatusMessage(`resume failed: ${result.reason ?? "unknown error"}`);
}
this.markDirty();
}
handleCancel() {
const waiting = this.snapshot?.waitingForApproval;
if (!waiting) {
this.setStatusMessage("press q to quit; c cancels only a pending approval");
this.markDirty();
return;
}
const result = this.session.cancel(waiting.stepKey, { actor: "cli", source: "tui" });
if (!result.ok) {
this.setStatusMessage(`cancel failed: ${result.reason ?? "unknown error"}`);
}
this.markDirty();
}
handleQuit() {
const status = this.snapshot?.status;
if (status && isTerminalStatus(status)) {
// Run already finished; cmdRun owns process exit from here.
this.markDirty();
return;
}
this.session.cancel(undefined, { actor: "cli", source: "tui", note: "cancelled from TUI" });
this.setStatusMessage("cancelling…");
this.markDirty();
}
moveSelection(delta) {
const steps = this.snapshot?.steps ?? [];
if (steps.length === 0) {
return;
}
const currentIndex = this.selectedStepKey ? steps.findIndex((step) => step.stepKey === this.selectedStepKey) : -1;
const baseIndex = currentIndex >= 0 ? currentIndex : delta > 0 ? -1 : 0;
const nextIndex = Math.min(Math.max(baseIndex + delta, 0), steps.length - 1);
this.selectedStepKey = steps[nextIndex].stepKey;
}
setStatusMessage(message) {
this.statusMessage = message;
this.statusMessageExpiresAt = this.nowFn() + STATUS_MESSAGE_TTL_MS;
}
currentStatusMessage(now) {
if (this.statusMessage && this.statusMessageExpiresAt !== null && now >= this.statusMessageExpiresAt) {
this.statusMessage = null;
this.statusMessageExpiresAt = null;
}
return this.statusMessage;
}
markDirty() {
this.dirty = true;
if (this.redrawMs === 0) {
this.renderNow();
}
}
composeFrame() {
const { columns, rows } = this.screen.size();
const now = this.nowFn();
return {
snapshot: this.snapshot,
stepDetails: this.stepDetails,
logs: this.logs,
selectedStepKey: this.selectedStepKey,
follow: this.follow,
attachUrl: this.attachUrl,
attachToken: this.attachToken,
statusMessage: this.currentStatusMessage(now),
width: columns,
height: rows,
now,
theme: this.theme,
};
}
}