openclaw / src /cli /update-cli /progress.ts
SaylorTwift's picture
SaylorTwift HF Staff
Add files using upload-large-folder tool
eb3f11e verified
Raw
History Blame Contribute Delete
8.03 kB
import { spinner } from "@clack/prompts";
import { UPDATE_RUN_PHASES } from "../../../packages/gateway-protocol/src/update-run-vocabulary.js";
import { theme } from "../../../packages/terminal-core/src/theme.js";
import { formatDurationPrecise } from "../../infra/format-time/format-duration.ts";
import { formatUpdateFailureFact } from "../../infra/update-failure-facts-format.js";
import { getUpdateRun } from "../../infra/update-run-ledger.js";
import {
updateStepDiagnostics,
type UpdateRunPhase,
type UpdateRunRecord,
} from "../../infra/update-run-record.js";
import {
renderUpdateRunReport,
updateRunReportInputFromResult,
} from "../../infra/update-run-report.js";
import type {
UpdateRunResult,
UpdateStepAdvisory,
UpdateStepProgress,
UpdateStepResult,
} from "../../infra/update-runner.js";
import { defaultRuntime } from "../../runtime.js";
import type { UpdateCommandOptions } from "./shared.js";
// One command owns each observer. The final report flushes it before printing so
// a fast final transition cannot appear after the report or leave a spinner active.
const activeUpdateProgress = new Map<string, (record: UpdateRunRecord | undefined) => void>();
const UPDATE_PROGRESS_POLL_MS = 250;
// These CLI-only callbacks can render the row just committed by their ledger owner.
export type UpdateDisplayProgress = {
onHeartbeat?: UpdateStepProgress["onHeartbeat"];
onStepStart?: (
step: Parameters<NonNullable<UpdateStepProgress["onStepStart"]>>[0],
record?: UpdateRunRecord,
) => void;
onStepComplete?: (
step: Parameters<NonNullable<UpdateStepProgress["onStepComplete"]>>[0],
record?: UpdateRunRecord,
) => void;
};
type ProgressController = {
progress: UpdateDisplayProgress;
stop: () => void;
suspend: () => void;
resume: () => void;
dispose: () => void;
};
export function createUpdateProgress(
enabled: boolean,
run?: UpdateCommandOptions["run"],
): ProgressController {
if (!enabled) {
return { progress: {}, stop: () => {}, suspend: () => {}, resume: () => {}, dispose: () => {} };
}
let currentSpinner: ReturnType<typeof spinner> | null = null;
let timer: ReturnType<typeof setTimeout> | undefined;
let currentPhase: UpdateRunPhase | undefined;
let observation: "active" | "suspended" | "disposed" = "active";
const seenPhases = new Set<UpdateRunPhase>();
const stop = () => {
currentSpinner?.clear();
currentSpinner = null;
};
const clearTimer = () => {
if (timer) {
clearTimeout(timer);
timer = undefined;
}
};
// Candidate migrations can advance the ledger beyond this process's reader.
// Step callbacks and final cleanup must respect the same fence as the timer.
const read = () =>
observation === "active" && run ? getUpdateRun(run.runId, { env: run.env }) : undefined;
const renderRecord = (record: UpdateRunRecord | undefined) => {
// Doctor's unbound spinner does not observe ledger phases, even after a write.
if (observation !== "active" || !run || !record) {
return;
}
currentPhase = record.phase;
// A child process can cross several phases between reads. Replay the recorded
// timeline rather than losing fast transitions or inferring unobserved phases.
for (const phase of UPDATE_RUN_PHASES) {
const recorded = record.steps.some(
(step) => step.step === phase && step.status !== "pending",
);
if (!seenPhases.has(phase) && (recorded || phase === record.phase)) {
seenPhases.add(phase);
stop();
defaultRuntime.log(`Phase: ${phase}`);
}
}
if (record.status !== "running") {
clearTimer();
}
};
const flush = (record: UpdateRunRecord | undefined) => {
renderRecord(record);
stop();
};
const poll = () => {
timer = undefined;
const record = read();
renderRecord(record);
if (record?.status === "running") {
// The CLI owns this poll only for its active operation; fresh-process
// finalization and gateway verification write the same ledger row.
timer = setTimeout(poll, UPDATE_PROGRESS_POLL_MS);
timer.unref?.();
}
};
if (run) {
// Initial observation can throw; publish only once the caller can own cleanup.
poll();
activeUpdateProgress.set(run.runId, flush);
}
const progress: UpdateDisplayProgress = {
onStepStart: (step, record) => {
flush(record ?? read());
const label = currentPhase ? `${currentPhase}${step.name}` : step.name;
if (process.stdout.isTTY) {
currentSpinner = spinner({ indicator: "timer" });
currentSpinner.start(theme.accent(label));
} else {
defaultRuntime.log(`${label}...`);
}
},
onStepComplete: (step, record) => {
flush(record ?? read());
printStep(step);
},
};
return {
progress,
stop,
suspend: () => {
if (observation === "active") {
observation = "suspended";
currentPhase = undefined;
clearTimer();
stop();
}
},
resume: () => {
if (observation === "suspended") {
observation = "active";
poll();
}
},
dispose: () => {
try {
renderRecord(read());
} finally {
observation = "disposed";
clearTimer();
if (run && activeUpdateProgress.get(run.runId) === flush) {
activeUpdateProgress.delete(run.runId);
}
stop();
}
},
};
}
type DisplayStep = Pick<
UpdateStepResult,
| "name"
| "durationMs"
| "exitCode"
| "advisory"
| "stdoutTail"
| "stderrTail"
| "termination"
| "signal"
| "failureFacts"
>;
function printStep(step: DisplayStep): void {
const duration = theme.muted(`(${formatDurationPrecise(step.durationMs)})`);
const termination =
step.termination === "timeout" || step.termination === "no-output-timeout"
? " — timed out"
: step.signal
? ` — interrupted (${step.signal})`
: "";
defaultRuntime.log(` ${formatStepStatus(step)} ${step.name}${termination} ${duration}`);
if (step.advisory === undefined && step.exitCode === 0) {
return;
}
if (!step.advisory && step.failureFacts?.length) {
for (const fact of step.failureFacts) {
defaultRuntime.log(` ${theme.error(formatUpdateFailureFact(fact))}`);
}
}
// Build tools often report failures on stdout. Keep the final diagnostic from
// each stream, so npm's stderr footer cannot hide the actual build error.
const color = step.advisory !== undefined ? theme.warn : theme.error;
if (step.advisory) {
defaultRuntime.log(` ${color(step.advisory.message)}`);
}
const tails = step.advisory
? [step.stdoutTail, step.stderrTail]
: updateStepDiagnostics(step).tails;
for (const output of tails) {
for (const line of (output ?? "").trimEnd().split("\n").slice(-10)) {
if (line.trim()) {
defaultRuntime.log(` ${color(line)}`);
}
}
}
}
function formatStepStatus(step: {
exitCode: number | null;
advisory?: UpdateStepAdvisory;
}): string {
if (step.advisory !== undefined) {
return theme.warn("!");
}
if (step.exitCode === 0) {
return theme.success("\u2713");
}
if (step.exitCode === null) {
return theme.warn("?");
}
return theme.error("\u2717");
}
export function printResult(
result: UpdateRunResult,
opts: UpdateCommandOptions,
reportHints: { doctorHint?: string | null; nextAction?: string } = {},
): void {
const run = result.runId ? getUpdateRun(result.runId, { env: opts.run?.env }) : undefined;
if (opts.json) {
defaultRuntime.writeJson({ ...result, ...(run ? { run } : {}) });
return;
}
if (result.runId) {
activeUpdateProgress.get(result.runId)?.(run);
}
const report = renderUpdateRunReport(run ?? updateRunReportInputFromResult(result), reportHints);
defaultRuntime.log("");
defaultRuntime.log(theme.heading(report.headline));
for (const line of report.lines) {
defaultRuntime.log(line);
}
}