[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
352
electron/bridges/mcpServerBridge/backgroundJobs.cjs
Normal file
352
electron/bridges/mcpServerBridge/backgroundJobs.cjs
Normal file
@@ -0,0 +1,352 @@
|
||||
/* eslint-disable no-undef */
|
||||
function createBackgroundJobApi(ctx) {
|
||||
with (ctx) {
|
||||
async function waitForSessionCloseCleanup(pending) {
|
||||
if (!pending.length) return;
|
||||
const allSettled = Promise.allSettled(pending);
|
||||
const timeoutMs = Number.isFinite(SESSION_CLOSE_CLEANUP_TIMEOUT_MS)
|
||||
? Math.max(1, SESSION_CLOSE_CLEANUP_TIMEOUT_MS)
|
||||
: 5000;
|
||||
let timer = null;
|
||||
const timeout = new Promise((resolve) => {
|
||||
timer = setTimeout(resolve, timeoutMs);
|
||||
});
|
||||
await Promise.race([allSettled, timeout]);
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
|
||||
function createBackgroundJobId() {
|
||||
return `job_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
}
|
||||
|
||||
function cancelBackgroundJobsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.chatSessionId !== chatSessionId) continue;
|
||||
if (job.status !== "running") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
} catch {
|
||||
// Ignore cancellation failures
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cancelBackgroundJobsForTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
for (const [, job] of backgroundJobs) {
|
||||
if (job.sessionId !== sessionId) continue;
|
||||
if (job.status !== "running" && job.status !== "stopping") continue;
|
||||
try {
|
||||
job.handle?.cancel?.();
|
||||
} catch {
|
||||
// The terminal close below will still tear down the underlying stream.
|
||||
}
|
||||
job.status = "stopping";
|
||||
job.error = "Cancellation requested";
|
||||
job.updatedAt = Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
async function settleBackgroundJobsForTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const matchingJobs = [];
|
||||
const pending = [];
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.sessionId !== sessionId) continue;
|
||||
matchingJobs.push([jobId, job]);
|
||||
if (job.handle?.resultPromise) pending.push(job.handle.resultPromise);
|
||||
}
|
||||
await waitForSessionCloseCleanup(pending);
|
||||
for (const [jobId] of matchingJobs) backgroundJobs.delete(jobId);
|
||||
activeSessionExecutions.delete(sessionId);
|
||||
}
|
||||
|
||||
function registerSftpOp(chatSessionId, sessionId, cancel) {
|
||||
if (!chatSessionId || typeof cancel !== "function") {
|
||||
return () => {};
|
||||
}
|
||||
if (closingTerminalSessions?.has(sessionId)) {
|
||||
try {
|
||||
void Promise.resolve(cancel()).catch(() => {});
|
||||
} catch {
|
||||
// The session is already closing; a failed redundant cancellation is harmless.
|
||||
}
|
||||
return () => {};
|
||||
}
|
||||
const opId = `sftp_${Date.now().toString(36)}_${(++activeSftpOpSeq).toString(36)}`;
|
||||
activeSessionSftpOps.set(opId, { chatSessionId, sessionId, cancel });
|
||||
return () => {
|
||||
activeSessionSftpOps.delete(opId);
|
||||
};
|
||||
}
|
||||
|
||||
async function cancelSftpOpsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
const pending = [];
|
||||
for (const [opId, entry] of activeSessionSftpOps) {
|
||||
if (entry.chatSessionId !== chatSessionId) continue;
|
||||
activeSessionSftpOps.delete(opId);
|
||||
try {
|
||||
pending.push(Promise.resolve(entry.cancel()));
|
||||
} catch {
|
||||
// Ignore cancellation failures for already-closed SFTP handles.
|
||||
}
|
||||
}
|
||||
if (pending.length) {
|
||||
await Promise.allSettled(pending);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelSftpOpsForTerminalSession(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const pending = [];
|
||||
for (const [opId, entry] of activeSessionSftpOps) {
|
||||
if (entry.sessionId !== sessionId) continue;
|
||||
activeSessionSftpOps.delete(opId);
|
||||
try {
|
||||
pending.push(Promise.resolve(entry.cancel()));
|
||||
} catch {
|
||||
// Ignore cancellation failures for already-closed SFTP handles.
|
||||
}
|
||||
}
|
||||
await waitForSessionCloseCleanup(pending);
|
||||
}
|
||||
|
||||
function beginTerminalSessionClose(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const current = closingTerminalSessions?.get(sessionId) || 0;
|
||||
closingTerminalSessions?.set(sessionId, current + 1);
|
||||
}
|
||||
|
||||
function endTerminalSessionClose(sessionId) {
|
||||
if (!sessionId) return;
|
||||
const current = closingTerminalSessions?.get(sessionId) || 0;
|
||||
if (current <= 1) closingTerminalSessions?.delete(sessionId);
|
||||
else closingTerminalSessions?.set(sessionId, current - 1);
|
||||
}
|
||||
|
||||
function cancelAllSftpOps() {
|
||||
const pending = [];
|
||||
for (const [opId, entry] of activeSessionSftpOps) {
|
||||
activeSessionSftpOps.delete(opId);
|
||||
try {
|
||||
pending.push(Promise.resolve(entry.cancel()));
|
||||
} catch {
|
||||
// Ignore cancellation failures during global cleanup.
|
||||
}
|
||||
}
|
||||
return pending.length ? Promise.allSettled(pending) : Promise.resolve([]);
|
||||
}
|
||||
|
||||
function readBackgroundJobSnapshot(job) {
|
||||
if (!job) {
|
||||
return {
|
||||
stdout: "",
|
||||
outputBaseOffset: 0,
|
||||
totalOutputChars: 0,
|
||||
outputTruncated: false,
|
||||
};
|
||||
}
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
const snapshot = job.handle?.getSnapshot?.();
|
||||
if (snapshot) {
|
||||
const stdout = String(snapshot.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(snapshot.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(snapshot.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(snapshot.outputTruncated),
|
||||
};
|
||||
}
|
||||
}
|
||||
const stdout = String(job.stdout || "");
|
||||
const outputBaseOffset = Math.max(0, Number(job.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, Number(job.totalOutputChars) || 0);
|
||||
return {
|
||||
stdout,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
};
|
||||
}
|
||||
|
||||
function createOutputWindow(stdout) {
|
||||
const fullText = String(stdout || "");
|
||||
const totalOutputChars = fullText.length;
|
||||
const outputBaseOffset = Math.max(0, totalOutputChars - MAX_BACKGROUND_JOB_OUTPUT_CHARS);
|
||||
return {
|
||||
stdout: outputBaseOffset > 0 ? fullText.slice(outputBaseOffset) : fullText,
|
||||
outputBaseOffset,
|
||||
totalOutputChars,
|
||||
outputTruncated: outputBaseOffset > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function refreshRunningJobSnapshot(job) {
|
||||
if (!job || (job.status !== "running" && job.status !== "stopping")) return;
|
||||
const snapshot = readBackgroundJobSnapshot(job);
|
||||
job.stdout = snapshot.stdout;
|
||||
job.outputBaseOffset = snapshot.outputBaseOffset;
|
||||
job.totalOutputChars = snapshot.totalOutputChars;
|
||||
job.outputTruncated = snapshot.outputTruncated;
|
||||
}
|
||||
|
||||
function storeCompletedJobOutput(job, stdout, metadata = null) {
|
||||
if (metadata && typeof metadata === "object") {
|
||||
const normalizedStdout = String(metadata.stdout ?? stdout ?? "");
|
||||
const outputBaseOffset = Math.max(0, Number(metadata.outputBaseOffset) || 0);
|
||||
const totalOutputChars = Math.max(outputBaseOffset + normalizedStdout.length, Number(metadata.totalOutputChars) || 0);
|
||||
job.stdout = normalizedStdout;
|
||||
job.outputBaseOffset = outputBaseOffset;
|
||||
job.totalOutputChars = totalOutputChars;
|
||||
job.outputTruncated = Boolean(metadata.outputTruncated);
|
||||
job.handle = null;
|
||||
return;
|
||||
}
|
||||
const window = createOutputWindow(stdout);
|
||||
job.stdout = window.stdout;
|
||||
job.outputBaseOffset = window.outputBaseOffset;
|
||||
job.totalOutputChars = window.totalOutputChars;
|
||||
job.outputTruncated = window.outputTruncated;
|
||||
job.handle = null;
|
||||
}
|
||||
|
||||
function pruneCompletedBackgroundJobs(now = Date.now()) {
|
||||
for (const [jobId, job] of backgroundJobs) {
|
||||
if (job.status === "running" || job.status === "stopping") continue;
|
||||
const updatedAt = Number(job.updatedAt) || 0;
|
||||
if (updatedAt > 0 && now - updatedAt > BACKGROUND_JOB_RETENTION_MS) {
|
||||
backgroundJobs.delete(jobId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse carriage-return progress redraws to the latest frame.
|
||||
// Each \r resets the cursor to the start of the current line; the next
|
||||
// non-\r character overwrites the existing line content. A trailing \r
|
||||
// (with no following content) leaves the existing line intact, so a
|
||||
// snapshot taken between redraws still shows the latest visible frame.
|
||||
// Used at serialize time so the stored buffer can keep raw monotonic
|
||||
// offsets while polled output shows the latest frame.
|
||||
function collapseCarriageReturns(text) {
|
||||
if (!text || text.indexOf("\r") === -1) return text;
|
||||
let result = "";
|
||||
let crPending = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i];
|
||||
if (ch === "\r") {
|
||||
crPending = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === "\n") {
|
||||
crPending = false;
|
||||
result += ch;
|
||||
continue;
|
||||
}
|
||||
if (crPending) {
|
||||
const lastNl = result.lastIndexOf("\n");
|
||||
result = lastNl >= 0 ? result.slice(0, lastNl + 1) : "";
|
||||
crPending = false;
|
||||
}
|
||||
result += ch;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function serializeBackgroundJob(job, offset = 0) {
|
||||
if (job.status === "running" || job.status === "stopping") {
|
||||
refreshRunningJobSnapshot(job);
|
||||
}
|
||||
const stdout = job.stdout || "";
|
||||
const outputBaseOffset = job.outputBaseOffset || 0;
|
||||
const totalOutputChars = Math.max(outputBaseOffset + stdout.length, job.totalOutputChars || 0);
|
||||
const numericOffset = Math.max(0, Number(offset) || 0);
|
||||
const relativeOffset = numericOffset <= outputBaseOffset
|
||||
? 0
|
||||
: Math.min(numericOffset - outputBaseOffset, stdout.length);
|
||||
return {
|
||||
ok: true,
|
||||
jobId: job.id,
|
||||
sessionId: job.sessionId,
|
||||
command: job.command,
|
||||
status: job.status,
|
||||
completed: job.status !== "running" && job.status !== "stopping",
|
||||
exitCode: job.exitCode,
|
||||
error: job.error,
|
||||
startedAt: job.startedAt,
|
||||
updatedAt: job.updatedAt,
|
||||
output: collapseCarriageReturns(stdout.slice(relativeOffset)),
|
||||
nextOffset: totalOutputChars,
|
||||
totalOutputChars,
|
||||
outputBaseOffset,
|
||||
outputTruncated: Boolean(job.outputTruncated),
|
||||
recommendedPollIntervalMs: DEFAULT_BACKGROUND_JOB_POLL_INTERVAL_MS,
|
||||
};
|
||||
}
|
||||
|
||||
function describeActiveSessionExecution(entry) {
|
||||
if (!entry) return "another command";
|
||||
return entry.kind === "job" ? "a long-running command" : "another command";
|
||||
}
|
||||
|
||||
function getSessionBusyError(sessionId) {
|
||||
const active = activeSessionExecutions.get(sessionId);
|
||||
if (!active) return null;
|
||||
return {
|
||||
ok: false,
|
||||
error: `Session already has ${describeActiveSessionExecution(active)} in progress. Wait for it to finish or stop it before starting another command.`,
|
||||
};
|
||||
}
|
||||
|
||||
function reserveSessionExecution(sessionId, kind) {
|
||||
const existing = getSessionBusyError(sessionId);
|
||||
if (existing) return existing;
|
||||
const token = `${kind}_${Date.now().toString(36)}_${crypto.randomBytes(6).toString("hex")}`;
|
||||
activeSessionExecutions.set(sessionId, {
|
||||
kind,
|
||||
startedAt: Date.now(),
|
||||
token,
|
||||
});
|
||||
return { ok: true, token };
|
||||
}
|
||||
|
||||
function releaseSessionExecution(sessionId, token) {
|
||||
const active = activeSessionExecutions.get(sessionId);
|
||||
if (!active) return;
|
||||
if (token && active.token !== token) return;
|
||||
activeSessionExecutions.delete(sessionId);
|
||||
}
|
||||
|
||||
return {
|
||||
createBackgroundJobId,
|
||||
cancelBackgroundJobsForSession,
|
||||
cancelBackgroundJobsForTerminalSession,
|
||||
settleBackgroundJobsForTerminalSession,
|
||||
registerSftpOp,
|
||||
cancelSftpOpsForSession,
|
||||
cancelSftpOpsForTerminalSession,
|
||||
beginTerminalSessionClose,
|
||||
endTerminalSessionClose,
|
||||
cancelAllSftpOps,
|
||||
readBackgroundJobSnapshot,
|
||||
createOutputWindow,
|
||||
refreshRunningJobSnapshot,
|
||||
storeCompletedJobOutput,
|
||||
pruneCompletedBackgroundJobs,
|
||||
collapseCarriageReturns,
|
||||
serializeBackgroundJob,
|
||||
describeActiveSessionExecution,
|
||||
getSessionBusyError,
|
||||
reserveSessionExecution,
|
||||
releaseSessionExecution,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createBackgroundJobApi };
|
||||
Reference in New Issue
Block a user