@workflow-manager/runner
Version:
CLI runner for in-memory and markdown workflow orchestration using ATEP-like envelopes
249 lines (248 loc) • 11.6 kB
JavaScript
import { describeAgentStatus, elapsedFrom, formatCount, isTerminalStatus, splitLines, stepLabel, } from "./runFormat.js";
export class CliRunRenderer {
verbose;
stream;
heartbeatMs;
steps = new Map();
lineBuffers = new Map();
lastSnapshot = null;
lastStepDetails = new Map();
heartbeat = null;
promptPauseCount = 0;
started = false;
closed = false;
constructor(options) {
this.verbose = options.verbose;
this.stream = options.stream ?? process.stderr;
this.heartbeatMs = options.heartbeatMs ?? 1000;
options.workflow.steps.forEach((step, index) => {
this.steps.set(step.key, { index, label: stepLabel(step) });
});
}
onSnapshot(snapshot, stepDetails) {
if (this.closed) {
return;
}
const previous = this.lastSnapshot;
this.lastSnapshot = snapshot;
this.lastStepDetails = new Map(stepDetails.map((detail) => [detail.stepKey, detail]));
if ((snapshot.status === "running" || snapshot.status === "waiting_for_approval") && !this.started) {
this.started = true;
this.write(`▶ Running workflow "${snapshot.workflowTitle}" (${formatCount(snapshot.steps.length, "step", "steps")})`);
}
this.syncHeartbeat(snapshot.status);
for (const step of snapshot.steps) {
const prevStatus = previous?.steps.find((entry) => entry.stepKey === step.stepKey)?.status;
if (prevStatus !== step.status) {
this.renderStepStatus(snapshot, step.stepKey, step.status);
}
}
if (previous?.status !== snapshot.status && snapshot.status === "waiting_for_approval" && snapshot.waitingForApproval) {
const waiting = snapshot.waitingForApproval;
const stepSummary = this.stepSummary(snapshot, waiting.stepKey);
const validation = waiting.validation ? ` (${waiting.validation})` : "";
this.write(`◌ ${stepSummary} waiting for approval${validation} - ${this.remainingText(snapshot)}`);
if (waiting.preview) {
this.write(` Approval summary: ${waiting.preview.summary}`);
for (const item of waiting.preview.items) {
const status = item.status ? ` [${item.status}]` : "";
this.write(` - ${item.title}${status}: ${item.summary}`);
}
}
this.write(" Resolve it in the terminal prompt or with `wfm approve` / `wfm cancel` using the attach API shown above.");
}
if (isTerminalStatus(snapshot.status)) {
this.stopHeartbeat();
this.flushBuffers();
this.closed = true;
}
}
onEvent(event) {
if (this.closed || !this.verbose) {
return;
}
if (event.type === "agent.started") {
const stepSummary = this.stepSummary(this.lastSnapshot, event.stepRunId);
const command = typeof event.payload.command === "string" ? event.payload.command : "agent";
const args = Array.isArray(event.payload.args) ? event.payload.args.map((value) => String(value)).join(" ") : "";
const model = typeof event.payload.model === "string" ? ` model=${event.payload.model}` : "";
this.write(` ${stepSummary} agent started: ${command}${args ? ` ${args}` : ""}${model}`);
return;
}
if (event.type === "agent.finished") {
const stepSummary = this.stepSummary(this.lastSnapshot, event.stepRunId);
const executionStatus = typeof event.payload.executionStatus === "string" ? ` ${event.payload.executionStatus}` : "";
const exitStatus = typeof event.payload.exitStatus === "number" || event.payload.exitStatus === null
? ` exit=${String(event.payload.exitStatus)}`
: "";
const timedOut = event.payload.timedOut === true ? " timedOut=true" : "";
this.write(` ${stepSummary} agent finished:${executionStatus}${exitStatus}${timedOut}`);
return;
}
if (event.type === "step.execution_finished") {
const stepSummary = this.stepSummary(this.lastSnapshot, event.stepRunId);
const status = typeof event.payload.status === "string" ? event.payload.status : "unknown";
const action = typeof event.payload.action === "string" ? ` action=${event.payload.action}` : "";
const feedbackReason = typeof event.payload.feedbackReason === "string" && event.payload.feedbackReason.length > 0
? ` reason=${event.payload.feedbackReason}`
: "";
this.write(` ${stepSummary} execution finished: ${status}${action}${feedbackReason}`);
return;
}
if (event.type === "step.validation_started") {
const stepSummary = this.stepSummary(this.lastSnapshot, event.stepRunId);
const adapter = typeof event.payload.adapter === "string" ? ` (${event.payload.adapter})` : "";
const criteria = typeof event.payload.criteria === "string" ? ` criteria=${event.payload.criteria}` : "";
this.write(` ${stepSummary} agent validation started${adapter}${criteria}`);
return;
}
if (event.type === "step.validation_finished") {
const stepSummary = this.stepSummary(this.lastSnapshot, event.stepRunId);
const status = typeof event.payload.status === "string" ? event.payload.status : "unknown";
const action = typeof event.payload.action === "string" ? ` action=${event.payload.action}` : "";
const feedbackReason = typeof event.payload.feedbackReason === "string" && event.payload.feedbackReason.length > 0
? ` reason=${event.payload.feedbackReason}`
: "";
this.write(` ${stepSummary} agent validation: ${status}${action}${feedbackReason}`);
return;
}
if (event.type === "step.retried") {
const stepSummary = this.stepSummary(this.lastSnapshot, event.stepRunId);
const attempt = typeof event.payload.attempt === "number" ? ` next attempt ${event.payload.attempt}` : " retry queued";
this.write(` ${stepSummary}${attempt}`);
}
}
onLog(log) {
if (this.closed || !this.verbose) {
return;
}
const stepKey = log.stepKey ?? "workflow";
const bufferKey = `${stepKey}:${log.stream}`;
const existing = this.lineBuffers.get(bufferKey) ?? "";
const { lines, remainder } = splitLines(existing + log.text);
this.lineBuffers.set(bufferKey, remainder);
for (const line of lines) {
this.write(` [${stepKey} ${log.stream}] ${line}`);
}
}
close() {
if (this.closed) {
return;
}
this.stopHeartbeat();
this.flushBuffers();
this.closed = true;
}
pauseHeartbeat() {
this.promptPauseCount += 1;
this.stopHeartbeat();
}
resumeHeartbeat() {
this.promptPauseCount = Math.max(0, this.promptPauseCount - 1);
if (this.promptPauseCount === 0 && this.lastSnapshot) {
this.syncHeartbeat(this.lastSnapshot.status);
}
}
renderStepStatus(snapshot, stepKey, status) {
if (!this.verbose && status === "runnable") {
return;
}
if (!this.verbose && status === "pending") {
return;
}
const summary = this.stepSummary(snapshot, stepKey);
const detail = this.lastStepDetails.get(stepKey);
if (status === "running") {
const adapter = detail?.adapter ? ` (${detail.adapter})` : "";
this.write(`→ ${summary} ${describeAgentStatus(status)}${adapter} - ${this.remainingText(snapshot)}`);
return;
}
if (status === "succeeded") {
const duration = detail?.startedAt ? ` in ${elapsedFrom(detail.startedAt)}` : "";
this.write(`✓ ${summary} ${describeAgentStatus(status)}${duration} - ${this.remainingText(snapshot)}`);
return;
}
if (status === "failed") {
const duration = detail?.startedAt ? ` after ${elapsedFrom(detail.startedAt)}` : "";
this.write(`✗ ${summary} ${describeAgentStatus(status)}${duration} - ${this.remainingText(snapshot)}`);
return;
}
if (status === "cancelled") {
this.write(`✗ ${summary} ${describeAgentStatus(status)} - ${this.remainingText(snapshot)}`);
return;
}
if (status === "waiting_for_approval") {
this.write(`◌ ${summary} ${describeAgentStatus(status)} - ${this.remainingText(snapshot)}`);
return;
}
this.write(`… ${summary} ${describeAgentStatus(status)} - ${this.remainingText(snapshot)}`);
}
stepSummary(snapshot, stepKey) {
if (!stepKey) {
return "[workflow]";
}
const descriptor = this.steps.get(stepKey);
const index = descriptor?.index ?? Math.max(0, snapshot?.steps.findIndex((step) => step.stepKey === stepKey) ?? 0);
const total = snapshot?.steps.length ?? this.steps.size;
const label = descriptor?.label ?? stepKey;
return `[${index + 1}/${Math.max(total, 1)}] ${stepKey}${label === stepKey ? "" : ` - ${label}`}`;
}
remainingText(snapshot) {
const completed = snapshot.steps.filter((step) => step.status === "succeeded").length;
const remaining = Math.max(snapshot.steps.length - completed, 0);
return `${formatCount(remaining, "step remaining", "steps remaining")} - workflow ${elapsedFrom(snapshot.startedAt)}`;
}
syncHeartbeat(status) {
if (this.promptPauseCount > 0) {
this.stopHeartbeat();
return;
}
if (status === "running" || status === "waiting_for_approval") {
if (!this.heartbeat) {
this.heartbeat = setInterval(() => {
this.renderHeartbeat();
}, this.heartbeatMs);
}
return;
}
this.stopHeartbeat();
}
stopHeartbeat() {
if (!this.heartbeat) {
return;
}
clearInterval(this.heartbeat);
this.heartbeat = null;
}
renderHeartbeat() {
if (!this.lastSnapshot || this.closed) {
return;
}
const snapshot = this.lastSnapshot;
if (snapshot.status === "running" && snapshot.currentStepKey) {
this.write(`.. ${this.stepSummary(snapshot, snapshot.currentStepKey)} running - ${this.remainingText(snapshot)}`);
return;
}
if (snapshot.status === "waiting_for_approval" && snapshot.waitingForApproval) {
const waiting = snapshot.waitingForApproval;
this.write(`.. ${this.stepSummary(snapshot, waiting.stepKey)} waiting - ${this.remainingText(snapshot)}`);
return;
}
if (snapshot.status === "running") {
this.write(`.. workflow running - ${this.remainingText(snapshot)}`);
}
}
flushBuffers() {
for (const [bufferKey, text] of this.lineBuffers.entries()) {
if (!text) {
continue;
}
const [stepKey, stream] = bufferKey.split(":");
this.write(` [${stepKey} ${stream}] ${text}`);
}
this.lineBuffers.clear();
}
write(line) {
this.stream.write(`${line}\n`);
}
}