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

View File

@@ -21,7 +21,7 @@ const {
} = require("./frp-config");
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"
if [ ! -s "$NVM_DIR/nvm.sh" ]; 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 Spinner from "ink-spinner";
import SelectInput from "ink-select-input";
@@ -31,8 +31,18 @@ function reducer(state, action) {
}
return { ...state, log: next };
}
case "awaiting-decision":
return { ...state, awaitingDecision: action.index };
case "decision-made":
return { ...state, awaitingDecision: null };
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:
return state;
}
@@ -47,16 +57,26 @@ export default function RunLog({ nav, plan, origin }) {
finished: false,
success: false,
cancelled: false,
awaitingDecision: null,
});
const runRef = useRef(null);
useEffect(() => {
const run = startRun(plan.steps);
runRef.current = run;
run.on("event", (event) => {
dispatch(event);
});
return () => run.cancel();
}, [plan]);
const handleDecision = (item) => {
if (runRef.current) {
runRef.current.decide(item.value);
}
dispatch({ type: "decision-made" });
};
const recentLog = state.log.slice(-MAX_LOG_LINES);
return (
@@ -82,7 +102,20 @@ export default function RunLog({ nav, plan, origin }) {
))}
</Box>
) : 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}>
<Text color="cyan">
<Spinner type="dots" />
@@ -168,6 +201,8 @@ function FinishedFooter({ state, nav, origin }) {
<Text color="yellow">Cancelled.</Text>
) : state.success ? (
<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>
)}