This commit is contained in:
2026-05-22 14:11:15 +08:00
parent 2a71566dcf
commit 249e361b59
3 changed files with 67 additions and 8 deletions

View File

@@ -7,9 +7,10 @@ const { spawn } = require("node:child_process");
function startRun(steps) { function startRun(steps) {
const emitter = new EventEmitter(); const emitter = new EventEmitter();
const state = { cancelled: false, child: null }; const state = { cancelled: false, child: null, pendingDecision: null };
setImmediate(async () => { setImmediate(async () => {
let hadFailure = false;
for (let index = 0; index < steps.length; index += 1) { for (let index = 0; index < steps.length; index += 1) {
if (state.cancelled) { if (state.cancelled) {
emitter.emit("event", { type: "done", success: false, cancelled: true }); emitter.emit("event", { type: "done", success: false, cancelled: true });
@@ -35,12 +36,27 @@ function startRun(steps) {
status: "failed", status: "failed",
error: error.message || String(error), error: error.message || String(error),
}); });
emitter.emit("event", { type: "done", success: false });
if (index === steps.length - 1) {
emitter.emit("event", { type: "done", success: false, hadFailure: true });
return; return;
} }
const decision = await new Promise((resolve) => {
state.pendingDecision = resolve;
emitter.emit("event", { type: "awaiting-decision", index });
});
state.pendingDecision = null;
if (decision !== "skip") {
emitter.emit("event", { type: "done", success: false, hadFailure: true });
return;
}
hadFailure = true;
}
} }
emitter.emit("event", { type: "done", success: true }); emitter.emit("event", { type: "done", success: !hadFailure, hadFailure });
}); });
return { return {
@@ -48,8 +64,16 @@ function startRun(steps) {
emitter.on(event, handler); emitter.on(event, handler);
return this; return this;
}, },
decide(value) {
if (state.pendingDecision) {
state.pendingDecision(value);
}
},
cancel() { cancel() {
state.cancelled = true; state.cancelled = true;
if (state.pendingDecision) {
state.pendingDecision("abort");
}
if (state.child) { if (state.child) {
try { try {
state.child.kill("SIGTERM"); state.child.kill("SIGTERM");

View File

@@ -21,7 +21,7 @@ const {
} = require("./frp-config"); } = require("./frp-config");
const NVM_INSTALL_VERSION = "v0.40.4"; const NVM_INSTALL_VERSION = "v0.40.4";
const NVM_INSTALL_COMMAND = `set -euo pipefail const NVM_INSTALL_COMMAND = `set -eo pipefail
export NVM_DIR="$HOME/.nvm" export NVM_DIR="$HOME/.nvm"
if [ ! -s "$NVM_DIR/nvm.sh" ]; then if [ ! -s "$NVM_DIR/nvm.sh" ]; then
if command -v curl >/dev/null 2>&1; then if command -v curl >/dev/null 2>&1; then

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useReducer } from "react"; import React, { useEffect, useReducer, useRef } from "react";
import { Box, Text } from "ink"; import { Box, Text } from "ink";
import Spinner from "ink-spinner"; import Spinner from "ink-spinner";
import SelectInput from "ink-select-input"; import SelectInput from "ink-select-input";
@@ -31,8 +31,18 @@ function reducer(state, action) {
} }
return { ...state, log: next }; return { ...state, log: next };
} }
case "awaiting-decision":
return { ...state, awaitingDecision: action.index };
case "decision-made":
return { ...state, awaitingDecision: null };
case "done": case "done":
return { ...state, finished: true, success: action.success, cancelled: !!action.cancelled }; return {
...state,
finished: true,
success: action.success,
cancelled: !!action.cancelled,
awaitingDecision: null,
};
default: default:
return state; return state;
} }
@@ -47,16 +57,26 @@ export default function RunLog({ nav, plan, origin }) {
finished: false, finished: false,
success: false, success: false,
cancelled: false, cancelled: false,
awaitingDecision: null,
}); });
const runRef = useRef(null);
useEffect(() => { useEffect(() => {
const run = startRun(plan.steps); const run = startRun(plan.steps);
runRef.current = run;
run.on("event", (event) => { run.on("event", (event) => {
dispatch(event); dispatch(event);
}); });
return () => run.cancel(); return () => run.cancel();
}, [plan]); }, [plan]);
const handleDecision = (item) => {
if (runRef.current) {
runRef.current.decide(item.value);
}
dispatch({ type: "decision-made" });
};
const recentLog = state.log.slice(-MAX_LOG_LINES); const recentLog = state.log.slice(-MAX_LOG_LINES);
return ( return (
@@ -82,7 +102,20 @@ export default function RunLog({ nav, plan, origin }) {
))} ))}
</Box> </Box>
) : null} ) : null}
{state.finished ? <FinishedFooter state={state} nav={nav} origin={origin} /> : ( {state.finished ? (
<FinishedFooter state={state} nav={nav} origin={origin} />
) : state.awaitingDecision != null ? (
<Box flexDirection="column" marginTop={1}>
<Text color="yellow">Step {state.awaitingDecision + 1} failed. Continue?</Text>
<SelectInput
items={[
{ label: "Skip and continue", value: "skip" },
{ label: "Abort run", value: "abort" },
]}
onSelect={handleDecision}
/>
</Box>
) : (
<Box marginTop={1}> <Box marginTop={1}>
<Text color="cyan"> <Text color="cyan">
<Spinner type="dots" /> <Spinner type="dots" />
@@ -168,6 +201,8 @@ function FinishedFooter({ state, nav, origin }) {
<Text color="yellow">Cancelled.</Text> <Text color="yellow">Cancelled.</Text>
) : state.success ? ( ) : state.success ? (
<Text color="green">All steps completed successfully.</Text> <Text color="green">All steps completed successfully.</Text>
) : Object.values(state.statuses).includes("failed") && !state.cancelled ? (
<Text color="yellow">Run finished with skipped failures.</Text>
) : ( ) : (
<Text color="red">Run failed.</Text> <Text color="red">Run failed.</Text>
)} )}