Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
1325 lines
42 KiB
JavaScript
1325 lines
42 KiB
JavaScript
"use strict";
|
|
|
|
const path = require("node:path");
|
|
const { AsyncLocalStorage } = require("node:async_hooks");
|
|
const { Worker } = require("node:worker_threads");
|
|
const { shellPromptPatterns } = require("./shellPromptPatterns.cjs");
|
|
|
|
const SCRIPT_SYNC_EXECUTION_TIMEOUT_MS = 1_000;
|
|
const SCRIPT_WORKER_START_TIMEOUT_MS = 10_000;
|
|
const SCRIPT_WORKER_MAX_OLD_GENERATION_MB = 64;
|
|
const SCRIPT_WORKER_MAX_YOUNG_GENERATION_MB = 16;
|
|
const SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS = 128;
|
|
const SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS = 512;
|
|
const SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS = 20_000;
|
|
const SCRIPT_WORKER_IMMEDIATE_PROGRESS_NOTIFICATIONS = 64;
|
|
const SCRIPT_WORKER_PROGRESS_THROTTLE_MS = 50;
|
|
const activeScriptWorkers = new Set();
|
|
const activeScriptHostRequests = new Set();
|
|
let prewarmedScriptWorker = null;
|
|
|
|
function createScriptWorkerHandle() {
|
|
const worker = new Worker(path.join(__dirname, "scriptExecutionWorker.cjs"), {
|
|
resourceLimits: {
|
|
maxOldGenerationSizeMb: SCRIPT_WORKER_MAX_OLD_GENERATION_MB,
|
|
maxYoungGenerationSizeMb: SCRIPT_WORKER_MAX_YOUNG_GENERATION_MB,
|
|
stackSizeMb: 4,
|
|
},
|
|
});
|
|
worker.unref();
|
|
let resolveReady;
|
|
let rejectReady;
|
|
const readyPromise = new Promise((resolve, reject) => {
|
|
resolveReady = resolve;
|
|
rejectReady = reject;
|
|
});
|
|
const onMessage = (message) => {
|
|
if (message?.type !== "ready") return;
|
|
worker.removeListener("message", onMessage);
|
|
worker.removeListener("error", onStartupError);
|
|
resolveReady();
|
|
};
|
|
const onStartupError = (error) => {
|
|
worker.removeListener("message", onMessage);
|
|
rejectReady(error);
|
|
};
|
|
worker.on("message", onMessage);
|
|
worker.once("error", onStartupError);
|
|
void readyPromise.catch(() => {});
|
|
return { worker, readyPromise };
|
|
}
|
|
|
|
function ensurePrewarmedScriptWorker() {
|
|
if (prewarmedScriptWorker) return prewarmedScriptWorker;
|
|
const handle = createScriptWorkerHandle();
|
|
prewarmedScriptWorker = handle;
|
|
void handle.readyPromise.catch(() => {
|
|
if (prewarmedScriptWorker === handle) prewarmedScriptWorker = null;
|
|
});
|
|
return handle;
|
|
}
|
|
|
|
function acquireScriptWorker() {
|
|
const handle = prewarmedScriptWorker || createScriptWorkerHandle();
|
|
if (prewarmedScriptWorker === handle) prewarmedScriptWorker = null;
|
|
handle.worker.ref();
|
|
ensurePrewarmedScriptWorker();
|
|
return handle;
|
|
}
|
|
|
|
function wrapScriptSource(source) {
|
|
const trimmed = String(source || "").trim();
|
|
if (!trimmed) {
|
|
throw new Error("Script content is empty");
|
|
}
|
|
|
|
if (/^\(\s*async\s*\(\s*\)\s*=>/m.test(trimmed) || /^\(\s*async\s+function\s*\(\s*\)\s*\{/m.test(trimmed)) {
|
|
return trimmed;
|
|
}
|
|
|
|
const hasMainFunction = /\basync\s+function\s+main\s*\(/m.test(trimmed) || /\bfunction\s+main\s*\(/m.test(trimmed);
|
|
if (hasMainFunction) {
|
|
const body = trimmed.replace(/\n\s*;?\s*await\s+main\s*\(\s*\)\s*;?\s*$/m, "").trimEnd();
|
|
const invokeMain = /\basync\s+function\s+main\s*\(/m.test(body)
|
|
? "await main();"
|
|
: "await Promise.resolve(main());";
|
|
return `(async () => {\n${body}\n${invokeMain}\n})();`;
|
|
}
|
|
|
|
return `(async () => {\n${trimmed}\n})();`;
|
|
}
|
|
|
|
function serializeWorkerError(error) {
|
|
return {
|
|
name: error?.name || "Error",
|
|
message: error?.message || String(error),
|
|
stack: typeof error?.stack === "string" ? error.stack : undefined,
|
|
};
|
|
}
|
|
|
|
function reviveWorkerError(value) {
|
|
const error = new Error(value?.message || "Script worker failed");
|
|
error.name = value?.name || "Error";
|
|
if (typeof value?.stack === "string") error.stack = value.stack;
|
|
return error;
|
|
}
|
|
|
|
function readRuntimeSnapshot(nct) {
|
|
return {
|
|
session: {
|
|
connected: Boolean(nct.session.connected),
|
|
name: String(nct.session.name || ""),
|
|
hostname: String(nct.session.hostname || ""),
|
|
username: String(nct.session.username || ""),
|
|
},
|
|
screen: {
|
|
currentRow: Number(nct.screen.currentRow) || 0,
|
|
rows: Number(nct.screen.rows) || 24,
|
|
cols: Number(nct.screen.cols) || 80,
|
|
},
|
|
};
|
|
}
|
|
|
|
const WORKER_RPC_METHODS = new Set([
|
|
"session.sleep",
|
|
"session.startLog",
|
|
"session.stopLog",
|
|
"session.disconnect",
|
|
"screen.send",
|
|
"screen.sendLine",
|
|
"screen.waitFor",
|
|
"screen.waitForText",
|
|
"screen.waitForRegex",
|
|
"screen.waitForPrompt",
|
|
"screen.waitForAny",
|
|
"screen.getText",
|
|
"screen.clear",
|
|
"dialog.alert",
|
|
"dialog.confirm",
|
|
"dialog.prompt",
|
|
"dialog.form",
|
|
"dialog.select",
|
|
"dialog.radio",
|
|
"dialog.checkbox",
|
|
]);
|
|
|
|
function invokeWorkerRpc(nct, method, args) {
|
|
if (!WORKER_RPC_METHODS.has(method)) {
|
|
throw new Error(`Unsupported script API method: ${method}`);
|
|
}
|
|
const [namespace, methodName] = method.split(".");
|
|
const target = nct[namespace];
|
|
const fn = target?.[methodName];
|
|
if (typeof fn !== "function") {
|
|
throw new Error(`Script API method unavailable: ${method}`);
|
|
}
|
|
return fn.apply(target, Array.isArray(args) ? args : []);
|
|
}
|
|
|
|
function handleWorkerNotification({ nct, appendLog, runId }, method, args) {
|
|
if (method === "log") {
|
|
nct.log(args?.[0]);
|
|
return;
|
|
}
|
|
if (method === "console.log") {
|
|
appendLog(runId, String(args?.[0] ?? ""));
|
|
return;
|
|
}
|
|
if (method.startsWith("progress.")) {
|
|
const methodName = method.slice("progress.".length);
|
|
const fn = nct.progress?.[methodName];
|
|
if (typeof fn !== "function") throw new Error(`Unsupported script notification: ${method}`);
|
|
fn.apply(nct.progress, Array.isArray(args) ? args : []);
|
|
return;
|
|
}
|
|
throw new Error(`Unsupported script notification: ${method}`);
|
|
}
|
|
|
|
function executeInScriptWorker({
|
|
source,
|
|
runId,
|
|
nct,
|
|
appendLog,
|
|
isAborted,
|
|
syncExecutionTimeoutMs,
|
|
registerStop,
|
|
executionContext,
|
|
executionToken,
|
|
}) {
|
|
const heartbeatIntervalMs = Math.max(
|
|
2,
|
|
Math.min(50, Math.floor(syncExecutionTimeoutMs / 4) || 2),
|
|
);
|
|
const heartbeatBuffer = new SharedArrayBuffer(BigInt64Array.BYTES_PER_ELEMENT);
|
|
const heartbeat = new BigInt64Array(heartbeatBuffer);
|
|
Atomics.store(heartbeat, 0, BigInt(Date.now()));
|
|
const workerHandle = acquireScriptWorker();
|
|
const { worker } = workerHandle;
|
|
activeScriptWorkers.add(worker);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let ready = false;
|
|
let watchdog = null;
|
|
const pendingHostRequests = new Set();
|
|
let notificationCount = 0;
|
|
let logNotificationCount = 0;
|
|
let progressNotificationCount = 0;
|
|
let pendingProgressNotification = null;
|
|
let progressTimer = null;
|
|
let progressCurrent = 0;
|
|
let progressTotal = 1;
|
|
let rejectHostRequests;
|
|
const hostRequestsClosed = new Promise((_, rejectClosed) => {
|
|
rejectHostRequests = rejectClosed;
|
|
});
|
|
void hostRequestsClosed.catch(() => {});
|
|
|
|
const cleanup = () => {
|
|
clearTimeout(startTimer);
|
|
if (watchdog) clearInterval(watchdog);
|
|
if (progressTimer) clearTimeout(progressTimer);
|
|
watchdog = null;
|
|
progressTimer = null;
|
|
pendingProgressNotification = null;
|
|
worker.removeAllListeners();
|
|
registerStop?.(() => {});
|
|
};
|
|
const finish = (error) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
executionToken.closed = true;
|
|
rejectHostRequests?.(error || new Error("Script execution finished"));
|
|
const pendingDrain = Promise.allSettled([...pendingHostRequests]);
|
|
cleanup();
|
|
void Promise.all([
|
|
worker.terminate().catch(() => {}),
|
|
pendingDrain,
|
|
]).then(() => {
|
|
pendingHostRequests.clear();
|
|
activeScriptWorkers.delete(worker);
|
|
if (error) reject(error);
|
|
else resolve(undefined);
|
|
});
|
|
};
|
|
registerStop?.((reason) => {
|
|
finish(reason instanceof Error ? reason : new Error("Script stopped"));
|
|
});
|
|
const postSnapshot = () => {
|
|
if (settled) return;
|
|
try {
|
|
worker.postMessage({ type: "snapshot", snapshot: readRuntimeSnapshot(nct) });
|
|
} catch {
|
|
// Worker may have crossed its terminal boundary between the checks.
|
|
}
|
|
};
|
|
const deliverNotification = (message) => {
|
|
executionContext.run(executionToken, () => {
|
|
handleWorkerNotification({ nct, appendLog, runId }, message.method, message.args);
|
|
});
|
|
};
|
|
const flushPendingProgress = () => {
|
|
if (progressTimer) clearTimeout(progressTimer);
|
|
progressTimer = null;
|
|
const pending = pendingProgressNotification;
|
|
pendingProgressNotification = null;
|
|
if (!pending || settled) return;
|
|
deliverNotification(pending);
|
|
};
|
|
const scheduleProgressFlush = () => {
|
|
if (progressTimer || settled) return;
|
|
progressTimer = setTimeout(() => {
|
|
progressTimer = null;
|
|
try {
|
|
flushPendingProgress();
|
|
} catch (error) {
|
|
finish(error);
|
|
}
|
|
}, SCRIPT_WORKER_PROGRESS_THROTTLE_MS);
|
|
progressTimer.unref?.();
|
|
};
|
|
const handleProgressNotification = (message) => {
|
|
const methodName = message.method.slice("progress.".length);
|
|
if (methodName === "start") {
|
|
flushPendingProgress();
|
|
progressCurrent = 0;
|
|
progressTotal = Math.max(1, Number(message.args?.[1]) || 1);
|
|
} else if (methodName === "set") {
|
|
progressCurrent = Math.max(0, Math.min(progressTotal, Number(message.args?.[0]) || 0));
|
|
} else if (methodName === "step") {
|
|
progressCurrent = Math.min(progressTotal, progressCurrent + 1);
|
|
} else if (methodName === "done") {
|
|
flushPendingProgress();
|
|
}
|
|
|
|
progressNotificationCount += 1;
|
|
if (
|
|
progressNotificationCount <= SCRIPT_WORKER_IMMEDIATE_PROGRESS_NOTIFICATIONS
|
|
|| methodName === "start"
|
|
|| methodName === "done"
|
|
) {
|
|
deliverNotification(message);
|
|
return;
|
|
}
|
|
pendingProgressNotification = {
|
|
type: "notify",
|
|
method: "progress.set",
|
|
args: [progressCurrent, message.args?.[1] ?? message.args?.[0]],
|
|
};
|
|
scheduleProgressFlush();
|
|
};
|
|
const startTimer = setTimeout(() => {
|
|
finish(new Error(`Script worker failed to start after ${SCRIPT_WORKER_START_TIMEOUT_MS}ms`));
|
|
}, SCRIPT_WORKER_START_TIMEOUT_MS);
|
|
startTimer.unref?.();
|
|
|
|
const startWatchdog = () => {
|
|
if (watchdog || settled) return;
|
|
const checkIntervalMs = Math.max(2, Math.min(25, heartbeatIntervalMs));
|
|
watchdog = setInterval(() => {
|
|
if (isAborted?.()) {
|
|
finish(new Error("Script stopped"));
|
|
return;
|
|
}
|
|
const lastHeartbeat = Number(Atomics.load(heartbeat, 0));
|
|
if (Date.now() - lastHeartbeat > syncExecutionTimeoutMs + heartbeatIntervalMs) {
|
|
finish(new Error(`Script execution timed out after ${syncExecutionTimeoutMs}ms`));
|
|
return;
|
|
}
|
|
postSnapshot();
|
|
}, checkIntervalMs);
|
|
watchdog.unref?.();
|
|
};
|
|
|
|
worker.on("message", (message) => {
|
|
if (settled) return;
|
|
if (message?.type === "notify") {
|
|
try {
|
|
notificationCount += 1;
|
|
if (notificationCount > SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS) {
|
|
finish(new Error(
|
|
`Script exceeded the ${SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS} notification limit`,
|
|
));
|
|
return;
|
|
}
|
|
if (message.method === "log" || message.method === "console.log") {
|
|
flushPendingProgress();
|
|
logNotificationCount += 1;
|
|
if (logNotificationCount > SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS) {
|
|
finish(new Error(
|
|
`Script exceeded the ${SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS} log notification limit`,
|
|
));
|
|
return;
|
|
}
|
|
deliverNotification(message);
|
|
return;
|
|
}
|
|
if (message.method?.startsWith("progress.")) {
|
|
handleProgressNotification(message);
|
|
return;
|
|
}
|
|
flushPendingProgress();
|
|
deliverNotification(message);
|
|
} catch (error) {
|
|
finish(error);
|
|
}
|
|
return;
|
|
}
|
|
if (message?.type === "rpc") {
|
|
if (pendingHostRequests.size >= SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS) {
|
|
finish(new Error(
|
|
`Script exceeded the ${SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS} pending host request limit`,
|
|
));
|
|
return;
|
|
}
|
|
const hostRequest = executionContext.run(executionToken, () => (
|
|
Promise.resolve().then(() => invokeWorkerRpc(nct, message.method, message.args))
|
|
));
|
|
// The underlying API observes executionToken and normally stops itself.
|
|
// Race it as well so one misbehaving host API cannot keep bookkeeping,
|
|
// timers, or the script completion promise alive after worker teardown.
|
|
void hostRequest.catch(() => {});
|
|
const request = Promise.race([hostRequest, hostRequestsClosed]);
|
|
pendingHostRequests.add(request);
|
|
activeScriptHostRequests.add(request);
|
|
void request.then(
|
|
(value) => {
|
|
if (settled) return;
|
|
worker.postMessage({
|
|
type: "rpc-result",
|
|
requestId: message.requestId,
|
|
ok: true,
|
|
value,
|
|
snapshot: readRuntimeSnapshot(nct),
|
|
});
|
|
},
|
|
(error) => {
|
|
if (settled) return;
|
|
worker.postMessage({
|
|
type: "rpc-result",
|
|
requestId: message.requestId,
|
|
ok: false,
|
|
error: serializeWorkerError(error),
|
|
snapshot: readRuntimeSnapshot(nct),
|
|
});
|
|
},
|
|
).finally(() => {
|
|
pendingHostRequests.delete(request);
|
|
activeScriptHostRequests.delete(request);
|
|
}).catch((error) => {
|
|
if (!settled) finish(error);
|
|
});
|
|
return;
|
|
}
|
|
if (message?.type === "completed") {
|
|
try { flushPendingProgress(); } catch (error) {
|
|
finish(error);
|
|
return;
|
|
}
|
|
finish();
|
|
return;
|
|
}
|
|
if (message?.type === "failed") {
|
|
finish(reviveWorkerError(message.error));
|
|
}
|
|
});
|
|
worker.once("error", (error) => finish(error));
|
|
worker.once("exit", (code) => {
|
|
if (!settled) {
|
|
finish(new Error(
|
|
ready
|
|
? `Script worker exited before completion (code ${code})`
|
|
: `Script worker failed to start (code ${code})`,
|
|
));
|
|
}
|
|
});
|
|
void workerHandle.readyPromise.then(() => {
|
|
if (settled) return;
|
|
ready = true;
|
|
clearTimeout(startTimer);
|
|
Atomics.store(heartbeat, 0, BigInt(Date.now()));
|
|
worker.postMessage({
|
|
type: "start",
|
|
config: {
|
|
source,
|
|
filename: `script-${runId}.js`,
|
|
version: nct.version,
|
|
snapshot: readRuntimeSnapshot(nct),
|
|
heartbeatBuffer,
|
|
heartbeatIntervalMs,
|
|
maxPendingHostRequests: SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS,
|
|
maxLogNotifications: SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS,
|
|
maxTotalNotifications: SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS,
|
|
},
|
|
});
|
|
startWatchdog();
|
|
}, (error) => finish(error));
|
|
});
|
|
}
|
|
|
|
function truncateActivityLabel(value, max = 80) {
|
|
const text = String(value ?? "");
|
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
}
|
|
|
|
function normalizeDialogOption(option) {
|
|
if (typeof option === "string") {
|
|
if (!option) {
|
|
throw new Error("Dialog option value is required");
|
|
}
|
|
return {
|
|
label: option,
|
|
value: option,
|
|
description: undefined,
|
|
disabled: false,
|
|
};
|
|
}
|
|
if (!option || typeof option !== "object") {
|
|
throw new Error("Dialog option must be a string or object");
|
|
}
|
|
const value = String(option.value ?? "");
|
|
if (!value) {
|
|
throw new Error("Dialog option value is required");
|
|
}
|
|
return {
|
|
label: String(option.label ?? value),
|
|
value,
|
|
description: option.description == null ? undefined : String(option.description),
|
|
disabled: Boolean(option.disabled),
|
|
};
|
|
}
|
|
|
|
function normalizeChoiceOptions(fieldType, options, defaultValue) {
|
|
if (!Array.isArray(options) || options.length === 0) {
|
|
throw new Error(`Dialog ${fieldType} field requires at least one option`);
|
|
}
|
|
const normalizedOptions = options.map(normalizeDialogOption);
|
|
const seenValues = new Set();
|
|
for (const option of normalizedOptions) {
|
|
if (seenValues.has(option.value)) {
|
|
throw new Error(`Dialog ${fieldType} field option values must be unique: ${option.value}`);
|
|
}
|
|
seenValues.add(option.value);
|
|
}
|
|
const firstEnabled = normalizedOptions.find((option) => !option.disabled);
|
|
if (!firstEnabled) {
|
|
throw new Error(`Dialog ${fieldType} field requires at least one enabled option`);
|
|
}
|
|
const defaultText = defaultValue == null ? undefined : String(defaultValue);
|
|
const selected = normalizedOptions.find((option) => option.value === defaultText && !option.disabled);
|
|
return {
|
|
options: normalizedOptions,
|
|
defaultValue: selected ? selected.value : firstEnabled.value,
|
|
};
|
|
}
|
|
|
|
function hasOwn(value, key) {
|
|
return Object.prototype.hasOwnProperty.call(value, key);
|
|
}
|
|
|
|
function normalizeConditionValue(value, context) {
|
|
const valueType = typeof value;
|
|
if (valueType === "string" || valueType === "boolean") {
|
|
return value;
|
|
}
|
|
if (valueType === "number" && Number.isFinite(value)) {
|
|
return value;
|
|
}
|
|
throw new Error(`${context} must be a string, number, or boolean`);
|
|
}
|
|
|
|
function normalizeDialogCondition(condition, context = "Dialog visibleWhen") {
|
|
if (!condition || typeof condition !== "object" || Array.isArray(condition)) {
|
|
throw new Error(`${context} must be an object`);
|
|
}
|
|
const field = String(condition.field ?? "").trim();
|
|
if (!field) {
|
|
throw new Error(`${context} field is required`);
|
|
}
|
|
const operators = ["equals", "notEquals", "truthy", "falsy"].filter((operator) => hasOwn(condition, operator));
|
|
if (operators.length !== 1) {
|
|
throw new Error(`${context} requires exactly one condition operator`);
|
|
}
|
|
const operator = operators[0];
|
|
if (operator === "truthy" || operator === "falsy") {
|
|
if (condition[operator] !== true) {
|
|
throw new Error(`${context} ${operator} must be true`);
|
|
}
|
|
return { field, [operator]: true };
|
|
}
|
|
return {
|
|
field,
|
|
[operator]: normalizeConditionValue(condition[operator], `${context} ${operator}`),
|
|
};
|
|
}
|
|
|
|
function matchesNumberStep(value, step, base = 0) {
|
|
const quotient = (value - base) / step;
|
|
return Math.abs(quotient - Math.round(quotient)) < 1e-9;
|
|
}
|
|
|
|
function normalizeDialogField(field, seenNames) {
|
|
if (!field || typeof field !== "object") {
|
|
throw new Error("Dialog form field must be an object");
|
|
}
|
|
const type = String(field.type ?? "");
|
|
if (!["select", "checkbox", "radio", "textarea", "number"].includes(type)) {
|
|
throw new Error(`Unsupported dialog field type: ${type || "unknown"}`);
|
|
}
|
|
const name = String(field.name ?? "").trim();
|
|
if (!name) {
|
|
throw new Error("Dialog form field name is required");
|
|
}
|
|
if (["__proto__", "prototype", "constructor"].includes(name)) {
|
|
throw new Error(`Dialog form field name is reserved: ${name}`);
|
|
}
|
|
if (seenNames.has(name)) {
|
|
throw new Error(`Duplicate dialog form field name: ${name}`);
|
|
}
|
|
seenNames.add(name);
|
|
|
|
const base = {
|
|
type,
|
|
name,
|
|
label: String(field.label ?? name),
|
|
description: field.description == null ? undefined : String(field.description),
|
|
required: field.required !== false,
|
|
visibleWhen: field.visibleWhen == null ? undefined : normalizeDialogCondition(field.visibleWhen),
|
|
};
|
|
|
|
if (type === "checkbox") {
|
|
return {
|
|
...base,
|
|
required: field.required === true,
|
|
defaultValue: Boolean(field.defaultValue),
|
|
};
|
|
}
|
|
|
|
if (type === "textarea") {
|
|
return {
|
|
...base,
|
|
placeholder: field.placeholder == null ? undefined : String(field.placeholder),
|
|
defaultValue: field.defaultValue == null ? "" : String(field.defaultValue),
|
|
};
|
|
}
|
|
|
|
if (type === "number") {
|
|
const defaultNumber = field.defaultValue === undefined || field.defaultValue === null || field.defaultValue === ""
|
|
? undefined
|
|
: Number(field.defaultValue);
|
|
if (defaultNumber !== undefined && !Number.isFinite(defaultNumber)) {
|
|
throw new Error(`Dialog number field defaultValue must be a finite number: ${name}`);
|
|
}
|
|
const min = field.min === undefined || field.min === null || field.min === "" ? undefined : Number(field.min);
|
|
const max = field.max === undefined || field.max === null || field.max === "" ? undefined : Number(field.max);
|
|
const step = field.step === undefined || field.step === null || field.step === "" ? undefined : Number(field.step);
|
|
if (min !== undefined && !Number.isFinite(min)) {
|
|
throw new Error(`Dialog number field min must be a finite number: ${name}`);
|
|
}
|
|
if (max !== undefined && !Number.isFinite(max)) {
|
|
throw new Error(`Dialog number field max must be a finite number: ${name}`);
|
|
}
|
|
if (step !== undefined && (!Number.isFinite(step) || step <= 0)) {
|
|
throw new Error(`Dialog number field step must be a positive finite number: ${name}`);
|
|
}
|
|
if (min !== undefined && max !== undefined && min > max) {
|
|
throw new Error(`Dialog number field min cannot be greater than max: ${name}`);
|
|
}
|
|
if (defaultNumber !== undefined && min !== undefined && defaultNumber < min) {
|
|
throw new Error(`Dialog number field defaultValue cannot be less than min: ${name}`);
|
|
}
|
|
if (defaultNumber !== undefined && max !== undefined && defaultNumber > max) {
|
|
throw new Error(`Dialog number field defaultValue cannot be greater than max: ${name}`);
|
|
}
|
|
if (
|
|
defaultNumber !== undefined
|
|
&& step !== undefined
|
|
&& min !== undefined
|
|
&& !matchesNumberStep(defaultNumber, step, min)
|
|
) {
|
|
throw new Error(`Dialog number field defaultValue must match step from min: ${name}`);
|
|
}
|
|
return {
|
|
...base,
|
|
placeholder: field.placeholder == null ? undefined : String(field.placeholder),
|
|
defaultValue: defaultNumber,
|
|
min,
|
|
max,
|
|
step,
|
|
};
|
|
}
|
|
|
|
const choice = normalizeChoiceOptions(type, field.options, field.defaultValue);
|
|
return {
|
|
...base,
|
|
options: choice.options,
|
|
defaultValue: choice.defaultValue,
|
|
};
|
|
}
|
|
|
|
function normalizeDialogFormSpec(spec) {
|
|
if (!spec || typeof spec !== "object") {
|
|
throw new Error("Dialog form spec must be an object");
|
|
}
|
|
if (!Array.isArray(spec.fields) || spec.fields.length === 0) {
|
|
throw new Error("Dialog form requires at least one field");
|
|
}
|
|
const seenNames = new Set();
|
|
const fields = spec.fields.map((field) => normalizeDialogField(field, seenNames));
|
|
const fieldIndexByName = new Map(fields.map((field, index) => [field.name, index]));
|
|
for (const [index, field] of fields.entries()) {
|
|
if (field.visibleWhen && !seenNames.has(field.visibleWhen.field)) {
|
|
throw new Error(`Dialog visibleWhen references unknown field: ${field.visibleWhen.field}`);
|
|
}
|
|
const dependencyIndex = field.visibleWhen ? fieldIndexByName.get(field.visibleWhen.field) : undefined;
|
|
if (dependencyIndex !== undefined && dependencyIndex >= index) {
|
|
throw new Error(`Dialog visibleWhen must reference an earlier field: ${field.name}`);
|
|
}
|
|
}
|
|
return {
|
|
title: spec.title == null ? undefined : String(spec.title),
|
|
message: spec.message == null ? "" : String(spec.message),
|
|
submitLabel: spec.submitLabel == null ? undefined : String(spec.submitLabel),
|
|
cancelLabel: spec.cancelLabel == null ? undefined : String(spec.cancelLabel),
|
|
fields,
|
|
};
|
|
}
|
|
|
|
function createScriptRuntime(deps) {
|
|
ensurePrewarmedScriptWorker();
|
|
const {
|
|
sessionId,
|
|
runId,
|
|
appendLog,
|
|
writeToSession,
|
|
getOutputBuffer,
|
|
getScreenSnapshot,
|
|
getSessionMeta,
|
|
showDialog,
|
|
showWaitForTimeoutDialog,
|
|
disconnectSession,
|
|
startSessionLog,
|
|
stopSessionLog,
|
|
onStatusChange,
|
|
isPaused,
|
|
permissionMode = "auto",
|
|
startedAt = Date.now(),
|
|
} = deps;
|
|
|
|
let stepIndex = 0;
|
|
let progressMode = "activity";
|
|
let progressLabel;
|
|
let progressCurrent = 0;
|
|
let progressTotal = 0;
|
|
let stopActiveExecution = () => {};
|
|
const executionContext = new AsyncLocalStorage();
|
|
|
|
let screenSnapshot = {
|
|
rows: 24,
|
|
cols: 80,
|
|
currentRow: 0,
|
|
lines: [],
|
|
};
|
|
|
|
function isExecutionAborted() {
|
|
return Boolean(executionContext.getStore()?.closed || deps.isAborted?.());
|
|
}
|
|
|
|
function assertNotAborted() {
|
|
if (isExecutionAborted()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
}
|
|
|
|
function abortable(promise) {
|
|
assertNotAborted();
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
const finish = (callback, value) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearInterval(timer);
|
|
callback(value);
|
|
};
|
|
const timer = setInterval(() => {
|
|
if (!isExecutionAborted()) return;
|
|
finish(reject, new Error("Script stopped"));
|
|
}, 50);
|
|
Promise.resolve(promise).then(
|
|
(value) => {
|
|
if (isExecutionAborted()) {
|
|
finish(reject, new Error("Script stopped"));
|
|
return;
|
|
}
|
|
finish(resolve, value);
|
|
},
|
|
(err) => {
|
|
if (isExecutionAborted()) {
|
|
finish(reject, new Error("Script stopped"));
|
|
return;
|
|
}
|
|
finish(reject, err);
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
function markHandled(promise) {
|
|
const observed = Promise.resolve(promise);
|
|
observed.catch(() => {});
|
|
return promise;
|
|
}
|
|
|
|
async function ignoreIfStopped(task) {
|
|
try {
|
|
return await task();
|
|
} catch (err) {
|
|
if (isExecutionAborted() && err?.message === "Script stopped") {
|
|
return undefined;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function emitStatus(patch = {}) {
|
|
onStatusChange?.(runId, {
|
|
progressMode,
|
|
progressLabel: progressMode === "determinate" ? progressLabel : undefined,
|
|
progressCurrent: progressMode === "determinate" ? progressCurrent : undefined,
|
|
progressTotal: progressMode === "determinate" ? progressTotal : undefined,
|
|
elapsedMs: Math.max(0, Date.now() - startedAt),
|
|
stepIndex,
|
|
status: "running",
|
|
...patch,
|
|
});
|
|
}
|
|
|
|
function assertWriteAllowed(operation) {
|
|
if (permissionMode !== "observer") return;
|
|
throw new Error(`Observer mode: ${operation} is disabled. Switch to Confirm or Auto mode.`);
|
|
}
|
|
|
|
async function trackStep(label) {
|
|
assertNotAborted();
|
|
stepIndex += 1;
|
|
const activityLabel = truncateActivityLabel(label);
|
|
emitStatus({
|
|
activityLabel,
|
|
currentStep: activityLabel,
|
|
});
|
|
}
|
|
|
|
async function refreshScreenSnapshot() {
|
|
assertNotAborted();
|
|
if (typeof getScreenSnapshot === "function") {
|
|
try {
|
|
screenSnapshot = await abortable(getScreenSnapshot(sessionId));
|
|
} catch {
|
|
// fall back to output buffer text
|
|
}
|
|
}
|
|
assertNotAborted();
|
|
return screenSnapshot;
|
|
}
|
|
|
|
async function waitForPromptWithRecovery(timeoutMs = 60000) {
|
|
let stepTracked = false;
|
|
while (true) {
|
|
if (isExecutionAborted()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
if (!stepTracked) {
|
|
await trackStep("waitForPrompt");
|
|
stepTracked = true;
|
|
}
|
|
onStatusChange?.(runId, { waitingFor: "shell prompt", status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
try {
|
|
return await getOutputBuffer(sessionId).waitForAny(
|
|
shellPromptPatterns(),
|
|
timeoutMs,
|
|
isExecutionAborted,
|
|
{ allowPreservedTailMatch: true },
|
|
);
|
|
} catch (err) {
|
|
if (!String(err?.message || err).includes("timed out")) {
|
|
throw err;
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", waitingFor: "shell prompt", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
assertNotAborted();
|
|
const action = await abortable(showWaitForTimeoutDialog?.("shell prompt", timeoutMs));
|
|
onStatusChange?.(runId, { status: "running" });
|
|
if (action === "retry") {
|
|
continue;
|
|
}
|
|
if (action === "skip") {
|
|
return -1;
|
|
}
|
|
throw new Error("Script stopped by user");
|
|
} finally {
|
|
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForAnyWithRecovery(patterns, timeoutMs = 30000) {
|
|
const label = Array.isArray(patterns)
|
|
? patterns.map((pattern) => (pattern instanceof RegExp ? pattern.source : String(pattern))).join(" | ")
|
|
: String(patterns);
|
|
let stepTracked = false;
|
|
while (true) {
|
|
if (isExecutionAborted()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
if (!stepTracked) {
|
|
await trackStep(`waitForAny ${truncateActivityLabel(label, 60)}`);
|
|
stepTracked = true;
|
|
}
|
|
onStatusChange?.(runId, { waitingFor: label, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
try {
|
|
return await getOutputBuffer(sessionId).waitForAny(
|
|
patterns,
|
|
timeoutMs,
|
|
isExecutionAborted,
|
|
);
|
|
} catch (err) {
|
|
if (!String(err?.message || err).includes("timed out")) {
|
|
throw err;
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", waitingFor: label, elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
assertNotAborted();
|
|
const action = await abortable(showWaitForTimeoutDialog?.(label, timeoutMs));
|
|
onStatusChange?.(runId, { status: "running" });
|
|
if (action === "retry") {
|
|
continue;
|
|
}
|
|
if (action === "skip") {
|
|
return -1;
|
|
}
|
|
throw new Error("Script stopped by user");
|
|
} finally {
|
|
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
}
|
|
}
|
|
|
|
async function waitForWithRecovery(pattern, timeoutMs = 30000, options = {}) {
|
|
const waitMethod = options.waitMethod || "waitFor";
|
|
const operationLabel = options.operationLabel || waitMethod;
|
|
const patternLabel = pattern instanceof RegExp ? pattern.source : String(pattern);
|
|
let stepTracked = false;
|
|
while (true) {
|
|
if (isExecutionAborted()) {
|
|
throw new Error("Script stopped");
|
|
}
|
|
if (!stepTracked) {
|
|
await trackStep(`${operationLabel} ${truncateActivityLabel(patternLabel, 60)}`);
|
|
stepTracked = true;
|
|
}
|
|
onStatusChange?.(runId, { waitingFor: patternLabel, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
try {
|
|
return await getOutputBuffer(sessionId)[waitMethod](
|
|
pattern,
|
|
timeoutMs,
|
|
isExecutionAborted,
|
|
);
|
|
} catch (err) {
|
|
if (!String(err?.message || err).includes("timed out")) {
|
|
throw err;
|
|
}
|
|
onStatusChange?.(runId, { status: "paused", waitingFor: patternLabel, elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
assertNotAborted();
|
|
const action = await abortable(showWaitForTimeoutDialog?.(patternLabel, timeoutMs));
|
|
onStatusChange?.(runId, { status: "running" });
|
|
if (action === "retry") {
|
|
continue;
|
|
}
|
|
if (action === "skip") {
|
|
return "";
|
|
}
|
|
throw new Error("Script stopped by user");
|
|
} finally {
|
|
onStatusChange?.(runId, { waitingFor: undefined, status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
}
|
|
}
|
|
|
|
const progressApi = {
|
|
start(label, total) {
|
|
progressMode = "determinate";
|
|
progressLabel = truncateActivityLabel(label || "Progress", 60);
|
|
progressTotal = Math.max(1, Number(total) || 1);
|
|
progressCurrent = 0;
|
|
emitStatus({
|
|
progressMode,
|
|
progressLabel,
|
|
progressCurrent,
|
|
progressTotal,
|
|
activityLabel: progressLabel,
|
|
});
|
|
},
|
|
set(current, detail) {
|
|
if (progressMode !== "determinate") return;
|
|
progressCurrent = Math.max(0, Math.min(progressTotal, Number(current) || 0));
|
|
const patch = {
|
|
progressCurrent,
|
|
progressTotal,
|
|
progressLabel,
|
|
};
|
|
if (detail !== undefined && detail !== null && detail !== "") {
|
|
patch.activityLabel = truncateActivityLabel(detail);
|
|
}
|
|
emitStatus(patch);
|
|
},
|
|
step(detail) {
|
|
if (progressMode !== "determinate") return;
|
|
progressCurrent = Math.min(progressTotal, progressCurrent + 1);
|
|
const patch = {
|
|
progressCurrent,
|
|
progressTotal,
|
|
progressLabel,
|
|
};
|
|
if (detail !== undefined && detail !== null && detail !== "") {
|
|
patch.activityLabel = truncateActivityLabel(detail);
|
|
}
|
|
emitStatus(patch);
|
|
},
|
|
done() {
|
|
if (progressMode !== "determinate") return;
|
|
progressCurrent = progressTotal;
|
|
emitStatus({
|
|
progressCurrent,
|
|
progressTotal,
|
|
progressLabel,
|
|
});
|
|
progressMode = "activity";
|
|
progressLabel = undefined;
|
|
progressCurrent = 0;
|
|
progressTotal = 0;
|
|
emitStatus({
|
|
progressMode: "activity",
|
|
progressLabel: undefined,
|
|
progressCurrent: undefined,
|
|
progressTotal: undefined,
|
|
});
|
|
},
|
|
};
|
|
|
|
const sessionApi = {
|
|
get connected() {
|
|
const meta = getSessionMeta?.(sessionId);
|
|
return Boolean(meta?.connected);
|
|
},
|
|
get name() {
|
|
return getSessionMeta?.(sessionId)?.name || "";
|
|
},
|
|
get hostname() {
|
|
return getSessionMeta?.(sessionId)?.hostname || "";
|
|
},
|
|
get username() {
|
|
return getSessionMeta?.(sessionId)?.username || "";
|
|
},
|
|
sleep(ms) {
|
|
const delay = Math.max(0, Number(ms) || 0);
|
|
return markHandled(trackStep(`sleep ${delay}ms`).then(() => interruptibleSleep(delay, isExecutionAborted)));
|
|
},
|
|
startLog(path) {
|
|
return markHandled(ignoreIfStopped(async () => {
|
|
if (isExecutionAborted()) return;
|
|
assertWriteAllowed("session.startLog");
|
|
await trackStep("startLog");
|
|
if (isExecutionAborted()) return;
|
|
await startSessionLog?.(sessionId, path);
|
|
}));
|
|
},
|
|
stopLog() {
|
|
return markHandled((async () => {
|
|
await stopSessionLog?.(sessionId);
|
|
})());
|
|
},
|
|
disconnect() {
|
|
return markHandled(ignoreIfStopped(async () => {
|
|
if (isExecutionAborted()) return;
|
|
assertWriteAllowed("session.disconnect");
|
|
await trackStep("disconnect");
|
|
if (isExecutionAborted()) return;
|
|
await disconnectSession?.(sessionId);
|
|
}));
|
|
},
|
|
};
|
|
|
|
const screenApi = {
|
|
send(text, options = {}) {
|
|
return markHandled(ignoreIfStopped(async () => {
|
|
if (isExecutionAborted()) return;
|
|
assertWriteAllowed("screen.send");
|
|
await waitIfPaused();
|
|
const payload = String(text ?? "");
|
|
const sensitive = options?.sensitive === true;
|
|
const visiblePayload = sensitive ? "[sensitive]" : formatScriptInputForLog(payload);
|
|
await trackStep(`send: ${truncateActivityLabel(visiblePayload, 60)}`);
|
|
if (isExecutionAborted()) return;
|
|
appendLog(runId, `→ ${visiblePayload}`);
|
|
writeToSession(sessionId, payload, { automated: true, sensitive });
|
|
}));
|
|
},
|
|
sendLine(text, options = {}) {
|
|
return markHandled(ignoreIfStopped(async () => {
|
|
if (isExecutionAborted()) return;
|
|
assertWriteAllowed("screen.sendLine");
|
|
await waitIfPaused();
|
|
const line = String(text ?? "");
|
|
const sensitive = options?.sensitive === true;
|
|
const visibleLine = sensitive ? "[sensitive]" : line;
|
|
await trackStep(`sendLine: ${truncateActivityLabel(visibleLine, 60)}`);
|
|
if (isExecutionAborted()) return;
|
|
appendLog(runId, `→ ${visibleLine}`);
|
|
// Bastion menus can ignore a single "line\r" packet even when
|
|
// stream.write succeeds. Match xterm: body, then Enter (#1960).
|
|
// Consume only pre-send buffer length so prompts that arrive between
|
|
// body and CR stay waitable for the next step.
|
|
const buffer = getOutputBuffer(sessionId);
|
|
const lengthBeforeSend = buffer.getText().length;
|
|
if (line.length > 0) {
|
|
writeToSession(sessionId, line, {
|
|
automated: true,
|
|
sensitive,
|
|
invalidateStartupSeed: false,
|
|
});
|
|
await interruptibleSleep(30, isExecutionAborted);
|
|
if (isExecutionAborted()) return;
|
|
}
|
|
writeToSession(sessionId, "\r", {
|
|
automated: true,
|
|
sensitive,
|
|
invalidateStartupSeed: false,
|
|
});
|
|
buffer.consumeThroughAbsolute(lengthBeforeSend);
|
|
}));
|
|
},
|
|
waitFor(pattern, timeoutMs = 30000) {
|
|
return markHandled(waitForWithRecovery(pattern, timeoutMs));
|
|
},
|
|
waitForText(text, timeoutMs = 30000) {
|
|
return markHandled(waitForWithRecovery(text, timeoutMs, {
|
|
waitMethod: "waitForText",
|
|
operationLabel: "waitForText",
|
|
}));
|
|
},
|
|
waitForRegex(pattern, timeoutMs = 30000) {
|
|
return markHandled(waitForWithRecovery(pattern, timeoutMs, {
|
|
waitMethod: "waitForRegex",
|
|
operationLabel: "waitForRegex",
|
|
}));
|
|
},
|
|
waitForPrompt(timeoutMs = 60000) {
|
|
return markHandled(waitForPromptWithRecovery(timeoutMs));
|
|
},
|
|
waitForAny(patterns, timeoutMs = 30000) {
|
|
return markHandled(waitForAnyWithRecovery(patterns, timeoutMs));
|
|
},
|
|
getText(startRow, endRow) {
|
|
return markHandled((async () => {
|
|
await refreshScreenSnapshot();
|
|
assertNotAborted();
|
|
const lines = screenSnapshot.lines || [];
|
|
const start = typeof startRow === "number" ? Math.max(0, startRow) : 0;
|
|
const end = typeof endRow === "number" ? Math.min(lines.length - 1, endRow) : lines.length - 1;
|
|
if (lines.length === 0) {
|
|
return getOutputBuffer(sessionId).getText();
|
|
}
|
|
return lines.slice(start, end + 1).join("\n");
|
|
})());
|
|
},
|
|
get currentRow() {
|
|
return screenSnapshot.currentRow ?? 0;
|
|
},
|
|
get rows() {
|
|
return screenSnapshot.rows ?? 24;
|
|
},
|
|
get cols() {
|
|
return screenSnapshot.cols ?? 80;
|
|
},
|
|
clear() {
|
|
return markHandled(ignoreIfStopped(async () => {
|
|
if (isExecutionAborted()) return;
|
|
assertWriteAllowed("screen.clear");
|
|
await trackStep("clear");
|
|
if (isExecutionAborted()) return;
|
|
writeToSession(sessionId, "\x1b[2J\x1b[H", { automated: true });
|
|
}));
|
|
},
|
|
};
|
|
|
|
const dialogApi = {
|
|
alert(message) {
|
|
assertNotAborted();
|
|
return markHandled(abortable(showDialog("alert", String(message ?? ""))));
|
|
},
|
|
confirm(message) {
|
|
assertNotAborted();
|
|
return markHandled(abortable(showDialog("confirm", String(message ?? ""))));
|
|
},
|
|
prompt(message, defaultValue = "", options = {}) {
|
|
assertNotAborted();
|
|
return markHandled(abortable(showDialog(
|
|
"prompt",
|
|
String(message ?? ""),
|
|
String(defaultValue ?? ""),
|
|
{ sensitive: options?.sensitive === true },
|
|
)));
|
|
},
|
|
form(spec) {
|
|
assertNotAborted();
|
|
const form = normalizeDialogFormSpec(spec);
|
|
return markHandled(abortable(showDialog("form", form.message, undefined, { form })));
|
|
},
|
|
select(message, options, defaultValue) {
|
|
return markHandled((async () => {
|
|
const values = await dialogApi.form({
|
|
message,
|
|
fields: [{
|
|
type: "select",
|
|
name: "value",
|
|
label: message,
|
|
options,
|
|
defaultValue,
|
|
}],
|
|
});
|
|
return String(values?.value ?? "");
|
|
})());
|
|
},
|
|
radio(message, options, defaultValue) {
|
|
return markHandled((async () => {
|
|
const values = await dialogApi.form({
|
|
message,
|
|
fields: [{
|
|
type: "radio",
|
|
name: "value",
|
|
label: message,
|
|
options,
|
|
defaultValue,
|
|
}],
|
|
});
|
|
return String(values?.value ?? "");
|
|
})());
|
|
},
|
|
checkbox(message, defaultChecked = false) {
|
|
return markHandled((async () => {
|
|
const values = await dialogApi.form({
|
|
message,
|
|
fields: [{
|
|
type: "checkbox",
|
|
name: "value",
|
|
label: message,
|
|
defaultValue: defaultChecked,
|
|
}],
|
|
});
|
|
return Boolean(values?.value);
|
|
})());
|
|
},
|
|
};
|
|
|
|
const nct = {
|
|
session: sessionApi,
|
|
screen: screenApi,
|
|
dialog: dialogApi,
|
|
progress: progressApi,
|
|
version: deps.appVersion || "0.0.0",
|
|
sleep: sessionApi.sleep.bind(sessionApi),
|
|
log(message) {
|
|
if (isExecutionAborted()) return;
|
|
assertNotAborted();
|
|
stepIndex += 1;
|
|
emitStatus({
|
|
activityLabel: "log",
|
|
currentStep: "log",
|
|
});
|
|
appendLog(runId, String(message ?? ""));
|
|
},
|
|
};
|
|
|
|
async function waitIfPaused() {
|
|
assertNotAborted();
|
|
while (isPaused?.()) {
|
|
assertNotAborted();
|
|
onStatusChange?.(runId, { status: "paused", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
await interruptibleSleep(100, isExecutionAborted);
|
|
}
|
|
assertNotAborted();
|
|
onStatusChange?.(runId, { status: "running", elapsedMs: Math.max(0, Date.now() - startedAt) });
|
|
}
|
|
|
|
async function execute(source) {
|
|
assertNotAborted();
|
|
const executionToken = { closed: false };
|
|
const configuredSyncTimeoutMs = Number(deps.syncExecutionTimeoutMs);
|
|
const syncExecutionTimeoutMs = Number.isFinite(configuredSyncTimeoutMs) && configuredSyncTimeoutMs > 0
|
|
? Math.floor(configuredSyncTimeoutMs)
|
|
: SCRIPT_SYNC_EXECUTION_TIMEOUT_MS;
|
|
await executeInScriptWorker({
|
|
source: wrapScriptSource(source),
|
|
runId,
|
|
nct,
|
|
appendLog,
|
|
isAborted: () => executionToken.closed || Boolean(deps.isAborted?.()),
|
|
syncExecutionTimeoutMs,
|
|
registerStop: (stop) => { stopActiveExecution = stop; },
|
|
executionContext,
|
|
executionToken,
|
|
});
|
|
assertNotAborted();
|
|
}
|
|
|
|
return {
|
|
execute,
|
|
nct,
|
|
stop(reason = new Error("Script stopped")) {
|
|
stopActiveExecution(reason);
|
|
},
|
|
};
|
|
}
|
|
|
|
function interruptibleSleep(ms, isAborted) {
|
|
const delay = Math.max(0, Number(ms) || 0);
|
|
if (!isAborted) {
|
|
return new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
return new Promise((resolve, reject) => {
|
|
const startedAt = Date.now();
|
|
const timer = setInterval(() => {
|
|
if (isAborted()) {
|
|
clearInterval(timer);
|
|
reject(new Error("Script stopped"));
|
|
return;
|
|
}
|
|
if (Date.now() - startedAt >= delay) {
|
|
clearInterval(timer);
|
|
resolve(undefined);
|
|
}
|
|
}, 50);
|
|
});
|
|
}
|
|
|
|
function formatScriptInputForLog(data) {
|
|
return String(data ?? "")
|
|
.replace(/\r/g, "\\r")
|
|
.replace(/\n/g, "\\n")
|
|
.replace(/\x03/g, "^C")
|
|
.replace(/\x1b/g, "\\e");
|
|
}
|
|
|
|
module.exports = {
|
|
SCRIPT_WORKER_MAX_PENDING_HOST_REQUESTS,
|
|
SCRIPT_WORKER_MAX_LOG_NOTIFICATIONS,
|
|
SCRIPT_WORKER_MAX_TOTAL_NOTIFICATIONS,
|
|
SCRIPT_WORKER_IMMEDIATE_PROGRESS_NOTIFICATIONS,
|
|
SCRIPT_SYNC_EXECUTION_TIMEOUT_MS,
|
|
createScriptRuntime,
|
|
wrapScriptSource,
|
|
interruptibleSleep,
|
|
formatScriptInputForLog,
|
|
normalizeDialogFormSpec,
|
|
_getActiveScriptWorkerCountForTests: () => activeScriptWorkers.size,
|
|
_getActiveScriptHostRequestCountForTests: () => activeScriptHostRequests.size,
|
|
};
|