| 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"; |
|
|
| |
| |
| const activeUpdateProgress = new Map<string, (record: UpdateRunRecord | undefined) => void>(); |
| const UPDATE_PROGRESS_POLL_MS = 250; |
|
|
| |
| 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; |
| } |
| }; |
| |
| |
| const read = () => |
| observation === "active" && run ? getUpdateRun(run.runId, { env: run.env }) : undefined; |
| const renderRecord = (record: UpdateRunRecord | undefined) => { |
| |
| if (observation !== "active" || !run || !record) { |
| return; |
| } |
| currentPhase = record.phase; |
| |
| |
| 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") { |
| |
| |
| timer = setTimeout(poll, UPDATE_PROGRESS_POLL_MS); |
| timer.unref?.(); |
| } |
| }; |
| if (run) { |
| |
| 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))}`); |
| } |
| } |
| |
| |
| 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); |
| } |
| } |
|
|