merge: codex deploy fallback

# Conflicts:
#	AGENTS.md
#	TEST.md
#	config.json
#	deploy.json
#	docs/reference/cli.md
#	docs/reference/microservices.md
#	docs/reference/observability.md
#	scripts/cli.ts
#	scripts/src/microservices.ts
#	src/components/backend-core/src/microservice-proxy.ts
#	src/components/microservices/code-queue/src/index.ts
#	src/components/microservices/code-queue/src/queue-api.ts
This commit is contained in:
Codex
2026-05-17 12:33:57 +00:00
20 changed files with 819 additions and 77 deletions
+18 -5
View File
@@ -3,7 +3,7 @@ import { debugDispatch, debugHealth, debugTask, isDebugDispatchCommand, type Deb
import { isRebuildableService, rebuildService, stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
import { parseE2ERunOptions, runE2E } from "./src/e2e";
import { emitError, emitJson } from "./src/output";
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
import { jobWithTail, listJobs, listJobsSummary, readJob, runJob } from "./src/jobs";
import { parseCheckOptions, runChecks } from "./src/check";
import { runSsh } from "./src/ssh";
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
@@ -15,6 +15,7 @@ import { runProviderCommand } from "./src/provider-attach";
import { runScheduleCommand } from "./src/schedules";
import { parseNetworkPerfOptions, runNetworkPerf } from "./src/network-perf";
import { runCiCommand } from "./src/ci";
import { runSwapCommand } from "./src/swap";
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
const args = remoteOptions.args;
@@ -32,6 +33,7 @@ function help(): unknown {
{ command: "server start", description: "Fire-and-forget build/start for database, backend-core, frontend, provider gateway, and managed main-server user services." },
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
{ command: "server swap status|ensure [--path /swapfile] [--size 2GiB] [--dry-run]", description: "Inspect or idempotently create host swap for low-memory main-server operation." },
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "server rebuild <backend-core|frontend|provider-gateway|todo-note|code-queue-mgr|project-manager|baidu-netdisk|oa-event-flow>", description: "Build first, then serialize, force-recreate, and validate one Compose service." },
{ command: "provider attach <providerId> [--master-server URL] [--up] [--force]", description: "Generate the minimal external provider-gateway env/compose bundle; only master server URL and provider id are required." },
@@ -61,7 +63,7 @@ function help(): unknown {
{ command: "codex judge <taskId> --attempt N [--dry-run] [--include-prompt]", description: "Replay one stored Code Queue attempt through the same judge context builder and MiniMax judge call path used by the live queue worker." },
{ command: "codex interrupt|cancel <taskId>", description: "Request interrupt for a running Code Queue task, or cancel a queued/retry_wait task, through the same private proxy." },
{ command: "codex (queues | queue create <queueId> | queue merge <sourceQueueId> --into <targetQueueId> | move <taskId> --queue <queueId>)", description: "List/create/merge Code Queue lanes and move a queued task; merge preserves task queue time order and deletes the source queue record." },
{ command: "job list", description: "List async jobs from .state/jobs." },
{ command: "job list [--limit N] [--include-command]", description: "List async jobs from .state/jobs with a bounded default page." },
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
@@ -82,6 +84,10 @@ function numberOption(name: string, defaultValue: number): number {
return value;
}
function boundedNumberOption(name: string, defaultValue: number, maxValue: number): number {
return Math.min(numberOption(name, defaultValue), maxValue);
}
function stringOption(name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
@@ -174,8 +180,15 @@ async function main(): Promise<void> {
emitJson(commandName, await stackStatus(config));
return;
}
if (sub === "swap") {
const result = runSwapCommand(args.slice(2));
const ok = (result as { ok?: unknown }).ok !== false;
emitJson(commandName, result, ok);
if (!ok) process.exitCode = 1;
return;
}
if (sub === "logs") {
emitJson(commandName, stackLogs(config, numberOption("--tail-bytes", 3000)));
emitJson(commandName, stackLogs(config, boundedNumberOption("--tail-bytes", 3000, 500_000)));
return;
}
if (sub === "rebuild") {
@@ -229,12 +242,12 @@ async function main(): Promise<void> {
if (top === "job") {
if (sub === "list") {
emitJson(commandName, { jobs: listJobs() });
emitJson(commandName, listJobsSummary({ limit: boundedNumberOption("--limit", 50, 500), includeCommand: args.includes("--include-command") }));
return;
}
if (sub === "status") {
const id = third === "latest" || third === undefined ? latestJobId() : third;
emitJson(commandName, { job: jobWithTail(readJob(id), numberOption("--tail-bytes", 12000)) });
emitJson(commandName, { job: jobWithTail(readJob(id), boundedNumberOption("--tail-bytes", 12000, 500_000)) });
return;
}
}
+13 -3
View File
@@ -1,5 +1,5 @@
import { spawn, spawnSync } from "node:child_process";
import { createWriteStream, existsSync, readFileSync } from "node:fs";
import { closeSync, createWriteStream, existsSync, openSync, readSync, statSync } from "node:fs";
export interface CommandResult {
command: string[];
@@ -56,6 +56,16 @@ export async function runCommandToFiles(command: string[], cwd: string, stdoutFi
export function tailFile(path: string, maxBytes = 8192): string {
if (!existsSync(path)) return "";
const content = readFileSync(path);
return content.subarray(Math.max(0, content.length - maxBytes)).toString("utf8");
const safeMaxBytes = Math.max(0, Math.floor(maxBytes));
if (safeMaxBytes === 0) return "";
const size = statSync(path).size;
const bytesToRead = Math.min(size, safeMaxBytes);
const buffer = Buffer.alloc(bytesToRead);
const fd = openSync(path, "r");
try {
readSync(fd, buffer, 0, bytesToRead, size - bytesToRead);
} finally {
closeSync(fd);
}
return buffer.toString("utf8");
}
+32 -4
View File
@@ -1,8 +1,9 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { basename, dirname, join, resolve } from "node:path";
import { commandOk, runCommand, tailFile } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { startJob } from "./jobs";
import { swapStatus } from "./swap";
export interface ComposeRuntimeEnv {
envFile: string;
@@ -436,6 +437,7 @@ export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
const overview = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/api/overview').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})");
return {
runtimeEnv,
swap: swapStatus(),
publicPorts: fixedPorts(config),
blockedPublicPorts: [
{ name: "backend-core-rest", port: config.network.core.port, listening: isPortListening(config.network.core.port), expected: "not-listening" },
@@ -500,11 +502,37 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown {
const allFiles = listLogFiles(logRoot);
const currentFiles = allFiles.filter((path) => basename(path).startsWith(runtimeEnv.logPrefix));
const selectedFiles = (currentFiles.length > 0 ? currentFiles : allFiles.slice(-12)).slice(-12);
const files = selectedFiles.map((path) => ({ path, name: basename(path), tail: tailFile(path, tailBytes) }));
const files = selectedFiles.map((path) => {
const sizeBytes = existsSync(path) ? statSync(path).size : 0;
const truncated = sizeBytes > tailBytes;
return { path, name: basename(path), sizeBytes, tailBytes, truncated, tail: tailFile(path, tailBytes) };
});
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "project-manager-backend", "baidu-netdisk-backend", "oa-event-flow-backend"];
const docker = containerNames.map((name) => {
const result = runCommand(["docker", "logs", "--tail", "40", name], repoRoot);
return { name, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-tailBytes), stderrTail: result.stderr.slice(-tailBytes) };
return {
name,
exitCode: result.exitCode,
tailBytes,
stdoutBytes: Buffer.byteLength(result.stdout, "utf8"),
stderrBytes: Buffer.byteLength(result.stderr, "utf8"),
stdoutTruncated: Buffer.byteLength(result.stdout, "utf8") > tailBytes,
stderrTruncated: Buffer.byteLength(result.stderr, "utf8") > tailBytes,
stdoutTail: result.stdout.slice(-tailBytes),
stderrTail: result.stderr.slice(-tailBytes),
};
});
return { logRoot, runtimeEnv, files, docker };
return {
logRoot,
runtimeEnv,
policy: {
defaultTailBytes: 3000,
requestedTailBytes: tailBytes,
selectedFileLimit: 12,
dockerTailLines: 40,
disclosure: "server logs returns tails only; increase with --tail-bytes for a larger bounded tail, and inspect listed paths directly for full logs.",
},
files,
docker,
};
}
+67 -3
View File
@@ -1,5 +1,5 @@
import { spawn, spawnSync } from "node:child_process";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommandToFiles, tailFile } from "./command";
@@ -141,6 +141,70 @@ export async function runJob(id: string): Promise<JobRecord> {
return job;
}
export function jobWithTail(job: JobRecord, maxBytes = 12000): JobRecord & { stdoutTail: string; stderrTail: string } {
return { ...job, stdoutTail: tailFile(job.stdoutFile, maxBytes), stderrTail: tailFile(job.stderrFile, maxBytes) };
export function jobWithTail(job: JobRecord, maxBytes = 12000): JobRecord & {
tailPolicy: {
requestedTailBytes: number;
stdoutBytes: number;
stderrBytes: number;
stdoutTruncated: boolean;
stderrTruncated: boolean;
fullLogPaths: { stdoutFile: string; stderrFile: string };
};
stdoutTail: string;
stderrTail: string;
} {
const stdoutBytes = existsSync(job.stdoutFile) ? statSync(job.stdoutFile).size : 0;
const stderrBytes = existsSync(job.stderrFile) ? statSync(job.stderrFile).size : 0;
return {
...job,
tailPolicy: {
requestedTailBytes: maxBytes,
stdoutBytes,
stderrBytes,
stdoutTruncated: stdoutBytes > maxBytes,
stderrTruncated: stderrBytes > maxBytes,
fullLogPaths: { stdoutFile: job.stdoutFile, stderrFile: job.stderrFile },
},
stdoutTail: tailFile(job.stdoutFile, maxBytes),
stderrTail: tailFile(job.stderrFile, maxBytes),
};
}
export interface JobListOptions {
limit?: number;
includeCommand?: boolean;
}
export function listJobsSummary(options: JobListOptions = {}): unknown {
const limit = Math.max(1, Math.floor(options.limit ?? 50));
const jobs = listJobs();
const returned = jobs.slice(0, limit).map((job) => ({
id: job.id,
name: job.name,
status: job.status,
runner: job.runner,
runnerPid: job.runnerPid ?? null,
runnerContainer: job.runnerContainer ?? null,
createdAt: job.createdAt,
startedAt: job.startedAt,
finishedAt: job.finishedAt,
exitCode: job.exitCode,
note: job.note,
stdoutFile: job.stdoutFile,
stderrFile: job.stderrFile,
...(options.includeCommand === true ? { command: job.command, cwd: job.cwd } : {}),
}));
return {
jobs: returned,
total: jobs.length,
returned: returned.length,
limit,
truncated: jobs.length > returned.length,
disclosure: {
defaultLimit: 50,
nextCommand: jobs.length > returned.length ? `bun scripts/cli.ts job list --limit ${Math.min(jobs.length, limit * 2)}` : null,
includeCommandCommand: "bun scripts/cli.ts job list --include-command",
statusCommand: "bun scripts/cli.ts job status <jobId> --tail-bytes 12000",
},
};
}
+74 -9
View File
@@ -3,18 +3,51 @@ import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
import { jsonByteLength, previewJson } from "./preview";
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
export function coreInternalFetch(path: string, init?: { method?: string; body?: unknown; maxResponseBytes?: number }): unknown {
if (!path.startsWith("/")) throw new Error("core internal path must start with /");
const maxResponseBytes = Math.max(1024, Math.floor(init?.maxResponseBytes ?? 5_000_000));
const code = `
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
method: init?.method ?? "GET",
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
})});
const text = await res.text();
const maxResponseBytes = ${JSON.stringify(maxResponseBytes)};
const reader = res.body?.getReader();
const chunks = [];
let bytes = 0;
let responseTruncated = false;
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (bytes + value.byteLength > maxResponseBytes) {
const keep = Math.max(0, maxResponseBytes - bytes);
if (keep > 0) {
chunks.push(value.slice(0, keep));
bytes += keep;
}
responseTruncated = true;
try { await reader.cancel(); } catch {}
break;
}
chunks.push(value);
bytes += value.byteLength;
}
}
const buffer = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder().decode(buffer);
let body = null;
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
try { body = text && !responseTruncated ? JSON.parse(text) : null; } catch { body = { text }; }
if (responseTruncated) {
body = { _unideskResponseTruncated: true, maxResponseBytes, bytesRead: bytes, contentLength: res.headers.get("content-length"), textPreview: text };
}
console.log(JSON.stringify({ ok: res.ok, status: res.status, responseTruncated, responseBytesRead: bytes, responseContentLength: res.headers.get("content-length"), body }));
`;
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
if (result.exitCode !== 0) {
@@ -51,6 +84,11 @@ function numberOption(args: string[], name: string, defaultValue: number): numbe
return value;
}
function cappedNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
const value = numberOption(args, name, defaultValue);
return Math.min(value, maxValue);
}
function stringOption(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
@@ -95,13 +133,34 @@ function methodOption(args: string[], hasBody = false): string {
}
export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown {
if (args.includes("--raw")) return response;
const maxBodyBytes = numberOption(args, "--max-body-bytes", 60_000);
const full = args.includes("--full");
const raw = args.includes("--raw");
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
if (typeof response !== "object" || response === null || Array.isArray(response)) return response;
const record = response as Record<string, unknown>;
if (!("body" in record)) return response;
if (record.responseTruncated === true) {
return {
...record,
bodyOmitted: true,
bodyMaxBytes: maxBodyBytes,
rawHint: "The upstream response exceeded the CLI collection cap before JSON parsing; re-run with --raw --full and a specific --max-body-bytes only when the full body is required.",
};
}
const bodyBytes = jsonByteLength(record.body);
if (bodyBytes <= maxBodyBytes) return response;
if (bodyBytes <= maxBodyBytes) {
if (!raw || full) return response;
return {
...record,
outputPolicy: {
rawRequested: true,
bounded: true,
maxBodyBytes,
bodyBytes,
fullCommand: "Re-run with --raw --full to allow the complete body.",
},
};
}
const rest = { ...record };
delete rest.body;
return {
@@ -110,7 +169,9 @@ export function summarizeMicroserviceProxyResponse(response: unknown, args: stri
bodyBytes,
bodyMaxBytes: maxBodyBytes,
bodyPreview: previewJson(record.body, { maxDepth: 3, maxArrayItems: 3, maxObjectKeys: 16, maxStringLength: 320 }),
rawHint: "Re-run with --raw for the full body, or add/tighten __unideskArrayLimit=<path>:<limit> in the proxied path.",
rawHint: raw && !full
? "The --raw response exceeded the default hard limit; re-run with --raw --full for the complete body, or add/tighten __unideskArrayLimit=<path>:<limit> in the proxied path."
: "Re-run with --raw --full for the complete body, or add/tighten __unideskArrayLimit=<path>:<limit> in the proxied path.",
};
}
@@ -137,7 +198,11 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
const id = requireId(idArg, "microservice proxy");
const path = requireProxyPath(pathArg);
const body = requestBodyOption(args);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body }), args);
const full = hasFlag(args, "--full");
const raw = hasFlag(args, "--raw");
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args, body !== undefined), body, maxResponseBytes }), args);
}
throw new Error("microservice command must be one of: list, status, health, diagnostics, tunnel-self-test, proxy");
}
+15 -4
View File
@@ -5,6 +5,20 @@ export interface JsonEnvelope<T> {
error?: unknown;
}
function isEpipe(error: unknown): boolean {
return typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE";
}
process.stdout.on("error", (error) => {
if (isEpipe(error)) process.exit(0);
throw error;
});
process.stderr.on("error", (error) => {
if (isEpipe(error)) process.exit(0);
throw error;
});
export function emitJson<T>(command: string, data: T, ok = true): void {
const envelope: JsonEnvelope<T> = { ok, command, data };
safeStdoutWrite(`${JSON.stringify(envelope, null, 2)}\n`);
@@ -22,10 +36,7 @@ function safeStdoutWrite(text: string): void {
try {
process.stdout.write(text);
} catch (error) {
if (typeof error === "object" && error !== null && "code" in error && (error as { code?: unknown }).code === "EPIPE") {
process.exitCode = 0;
return;
}
if (isEpipe(error)) process.exit(0);
throw error;
}
}
+53 -7
View File
@@ -27,6 +27,9 @@ interface FetchJsonResult {
status?: number;
body?: unknown;
error?: string;
responseTruncated?: boolean;
responseBytesRead?: number;
responseContentLength?: string | null;
}
const hostOptions = new Set(["--main-server-ip", "--main-server", "--server"]);
@@ -172,19 +175,54 @@ function frontendBaseUrl(host: string, config: UniDeskConfig): string {
return `http://${host}:${config.network.frontend.port}`;
}
async function readJson(url: string, init?: RequestInit, timeoutMs = 8000): Promise<FetchJsonResult> {
async function readJson(url: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: controller.signal });
const text = await res.text();
const reader = res.body?.getReader();
const chunks: Uint8Array[] = [];
let bytes = 0;
let responseTruncated = false;
if (reader !== undefined) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (bytes + value.byteLength > maxResponseBytes) {
const keep = Math.max(0, maxResponseBytes - bytes);
if (keep > 0) {
chunks.push(value.slice(0, keep));
bytes += keep;
}
responseTruncated = true;
try {
await reader.cancel();
} catch {
// Ignore cancel failures after the bounded preview has been collected.
}
break;
}
chunks.push(value);
bytes += value.byteLength;
}
}
const buffer = new Uint8Array(bytes);
let offset = 0;
for (const chunk of chunks) {
buffer.set(chunk, offset);
offset += chunk.byteLength;
}
const text = new TextDecoder().decode(buffer);
let body: unknown = null;
try {
body = text.length > 0 ? JSON.parse(text) : null;
body = text.length > 0 && !responseTruncated ? JSON.parse(text) : null;
} catch {
body = { text };
}
return { ok: res.ok, status: res.status, body };
if (responseTruncated) {
body = { _unideskResponseTruncated: true, maxResponseBytes, bytesRead: bytes, contentLength: res.headers.get("content-length"), textPreview: text };
}
return { ok: res.ok, status: res.status, body, responseTruncated, responseBytesRead: bytes, responseContentLength: res.headers.get("content-length") };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : String(error) };
} finally {
@@ -208,11 +246,11 @@ async function loginFrontend(host: string, config: UniDeskConfig): Promise<Front
return { baseUrl, cookie };
}
async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000): Promise<FetchJsonResult> {
async function frontendJson(session: FrontendSession, path: string, init?: RequestInit, timeoutMs = 8000, maxResponseBytes = 5_000_000): Promise<FetchJsonResult> {
const headers = new Headers(init?.headers);
headers.set("cookie", session.cookie);
if (init?.body !== undefined && !headers.has("content-type")) headers.set("content-type", "application/json");
return readJson(`${session.baseUrl}${path}`, { ...init, headers }, timeoutMs);
return readJson(`${session.baseUrl}${path}`, { ...init, headers }, timeoutMs, maxResponseBytes);
}
function stringOption(args: string[], name: string): string | undefined {
@@ -231,6 +269,10 @@ function numberOption(args: string[], name: string, defaultValue: number): numbe
return value;
}
function cappedNumberOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
return Math.min(numberOption(args, name, defaultValue), maxValue);
}
function jsonOption(args: string[], name: string): Record<string, unknown> | undefined {
const raw = stringOption(args, name);
if (raw === undefined) return undefined;
@@ -462,7 +504,11 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
};
}
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000);
const full = args.includes("--full");
const raw = args.includes("--raw");
const maxBodyBytes = full ? numberOption(args, "--max-body-bytes", 5_000_000) : cappedNumberOption(args, "--max-body-bytes", raw ? 120_000 : 60_000, 500_000);
const maxResponseBytes = full ? Math.min(Math.max(maxBodyBytes, 120_000), 5_000_000) : Math.min(Math.max(maxBodyBytes * 3, 240_000), 1_500_000);
const response = await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000, maxResponseBytes);
return {
transport: "frontend",
response: summarizeMicroserviceProxyResponse(response, args),
+303
View File
@@ -0,0 +1,303 @@
import { accessSync, constants, existsSync, readFileSync, statSync } from "node:fs";
import { runCommand } from "./command";
import { repoRoot } from "./config";
const defaultSwapPath = "/swapfile";
const defaultSwapSizeBytes = 2 * 1024 * 1024 * 1024;
export interface SwapArea {
filename: string;
type: string;
sizeBytes: number;
usedBytes: number;
priority: number | null;
}
export interface SwapMemoryStatus {
totalBytes: number;
availableBytes: number | null;
swapTotalBytes: number;
swapFreeBytes: number;
}
export interface SwapStatus {
memory: SwapMemoryStatus;
activeSwaps: SwapArea[];
configuredPath: string;
configuredPathExists: boolean;
configuredPathMode: string | null;
configuredPathSizeBytes: number | null;
configuredPathActive: boolean;
fstab: {
path: string;
writable: boolean;
persisted: boolean;
matchingLine: string | null;
error: string | null;
};
warning: string | null;
}
export interface SwapEnsureResult {
ok: boolean;
status: "ok" | "degraded" | "failed";
requested: {
path: string;
sizeBytes: number;
};
before: SwapStatus;
after: SwapStatus;
actions: Array<{ action: string; ok: boolean; detail?: unknown }>;
errors: Array<{ action: string; message: string; detail?: unknown }>;
}
function shellQuote(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function parseByteCount(value: string): number {
const raw = value.trim();
if (/^\d+$/u.test(raw)) return Number(raw);
const match = raw.match(/^([0-9]+(?:\.[0-9]+)?)([KMGTPE]?i?B?)$/iu);
if (!match) return 0;
const amount = Number(match[1]);
const unit = match[2].toUpperCase();
const powers: Record<string, number> = {
K: 1,
KB: 1,
KIB: 1,
M: 2,
MB: 2,
MIB: 2,
G: 3,
GB: 3,
GIB: 3,
T: 4,
TB: 4,
TIB: 4,
P: 5,
PB: 5,
PIB: 5,
E: 6,
EB: 6,
EIB: 6,
};
return Math.round(amount * (1024 ** (powers[unit] ?? 0)));
}
function parseMeminfo(): SwapMemoryStatus {
const raw = readFileSync("/proc/meminfo", "utf8");
const values = new Map<string, number>();
for (const line of raw.split("\n")) {
const match = line.match(/^([^:]+):\s+(\d+)\s+kB/u);
if (match) values.set(match[1], Number(match[2]) * 1024);
}
return {
totalBytes: values.get("MemTotal") ?? 0,
availableBytes: values.get("MemAvailable") ?? null,
swapTotalBytes: values.get("SwapTotal") ?? 0,
swapFreeBytes: values.get("SwapFree") ?? 0,
};
}
function parseSwaps(): SwapArea[] {
if (!existsSync("/proc/swaps")) return [];
const lines = readFileSync("/proc/swaps", "utf8").trim().split("\n").slice(1);
return lines.map((line) => line.trim().split(/\s+/u)).filter((parts) => parts.length >= 5).map(([filename, type, sizeKiB, usedKiB, priority]) => ({
filename,
type,
sizeBytes: Number(sizeKiB) * 1024,
usedBytes: Number(usedKiB) * 1024,
priority: Number.isFinite(Number(priority)) ? Number(priority) : null,
}));
}
function fileMode(path: string): string | null {
if (!existsSync(path)) return null;
return (statSync(path).mode & 0o777).toString(8).padStart(3, "0");
}
function fstabStatus(path: string): SwapStatus["fstab"] {
const fstabPath = "/etc/fstab";
try {
const raw = existsSync(fstabPath) ? readFileSync(fstabPath, "utf8") : "";
let writable = false;
try {
accessSync(fstabPath, constants.W_OK);
writable = true;
} catch {
writable = false;
}
const matchingLine = raw.split("\n").find((line) => {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed.startsWith("#")) return false;
const parts = trimmed.split(/\s+/u);
return parts[0] === path && parts[2] === "swap";
}) ?? null;
return {
path: fstabPath,
writable,
persisted: matchingLine !== null,
matchingLine,
error: null,
};
} catch (error) {
return {
path: fstabPath,
writable: false,
persisted: false,
matchingLine: null,
error: error instanceof Error ? error.message : String(error),
};
}
}
export function swapStatus(path = defaultSwapPath): SwapStatus {
const memory = parseMeminfo();
const activeSwaps = parseSwaps();
const configuredPathExists = existsSync(path);
const configuredPathSizeBytes = configuredPathExists ? statSync(path).size : null;
const configuredPathActive = activeSwaps.some((swap) => swap.filename === path);
const warning = memory.swapTotalBytes > 0 ? null : "swap is not active; low-memory main servers are at risk of global OOM during builds or diagnostics";
return {
memory,
activeSwaps,
configuredPath: path,
configuredPathExists,
configuredPathMode: fileMode(path),
configuredPathSizeBytes,
configuredPathActive,
fstab: fstabStatus(path),
warning,
};
}
function pushAction(
actions: SwapEnsureResult["actions"],
errors: SwapEnsureResult["errors"],
action: string,
command: string[],
): boolean {
const result = runCommand(command, repoRoot, { timeoutMs: 120_000 });
const ok = result.exitCode === 0;
const detail = {
command,
exitCode: result.exitCode,
stdoutTail: result.stdout.slice(-1200),
stderrTail: result.stderr.slice(-1200),
timedOut: result.timedOut,
};
actions.push({ action, ok, detail });
if (!ok) {
errors.push({
action,
message: result.stderr.trim() || result.stdout.trim() || `command failed with exit code ${result.exitCode}`,
detail,
});
}
return ok;
}
function ensureFstabLine(path: string): { ok: boolean; action: string; detail: unknown } {
const line = `${path} none swap sw 0 0`;
const script = [
"set -euo pipefail",
"touch /etc/fstab",
`grep -Eq '^${path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}[[:space:]]+[^[:space:]]+[[:space:]]+swap[[:space:]]' /etc/fstab || printf '%s\\n' ${shellQuote(line)} >> /etc/fstab`,
].join("\n");
const result = runCommand(["bash", "-lc", script], repoRoot, { timeoutMs: 30_000 });
return {
ok: result.exitCode === 0,
action: "persist-fstab",
detail: {
command: ["bash", "-lc", script],
exitCode: result.exitCode,
stdoutTail: result.stdout.slice(-1200),
stderrTail: result.stderr.slice(-1200),
timedOut: result.timedOut,
},
};
}
function parseSizeOption(args: string[], defaultBytes: number): number {
const index = args.indexOf("--size");
const raw = index === -1 ? undefined : args[index + 1];
if (raw === undefined) return defaultBytes;
const bytes = parseByteCount(raw);
if (!Number.isFinite(bytes) || bytes <= 0) throw new Error("--size must be a positive byte count such as 2GiB or 4096M");
return bytes;
}
function parsePathOption(args: string[], defaultPath: string): string {
const index = args.indexOf("--path");
if (index === -1) return defaultPath;
const raw = args[index + 1];
if (raw === undefined || !raw.startsWith("/")) throw new Error("--path must be an absolute path");
return raw;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
export function runSwapCommand(args: string[]): unknown {
const [action = "status"] = args;
const path = parsePathOption(args, defaultSwapPath);
if (action === "status") return swapStatus(path);
if (action === "ensure") {
const sizeBytes = parseSizeOption(args, defaultSwapSizeBytes);
const dryRun = hasFlag(args, "--dry-run");
const before = swapStatus(path);
const actions: SwapEnsureResult["actions"] = [];
const errors: SwapEnsureResult["errors"] = [];
if (before.memory.swapTotalBytes > 0) {
actions.push({ action: "noop-existing-swap", ok: true, detail: { activeSwaps: before.activeSwaps } });
const after = swapStatus(path);
return { ok: true, status: "ok", requested: { path, sizeBytes }, before, after, actions, errors } satisfies SwapEnsureResult;
}
if (dryRun) {
actions.push({ action: "dry-run", ok: true, detail: { wouldCreate: path, sizeBytes, wouldPersistFstab: true } });
const after = swapStatus(path);
return { ok: true, status: "degraded", requested: { path, sizeBytes }, before, after, actions, errors } satisfies SwapEnsureResult;
}
if (!existsSync(path)) {
const sizeMiB = Math.ceil(sizeBytes / 1024 / 1024);
const allocated = pushAction(actions, errors, "allocate-swapfile", ["fallocate", "-l", `${sizeMiB}M`, path]);
if (!allocated) pushAction(actions, errors, "allocate-swapfile-dd-fallback", ["dd", "if=/dev/zero", `of=${path}`, "bs=1M", `count=${sizeMiB}`, "status=none"]);
} else {
const existingBytes = statSync(path).size;
if (existingBytes < sizeBytes) {
const sizeMiB = Math.ceil(sizeBytes / 1024 / 1024);
const resized = pushAction(actions, errors, "resize-existing-swapfile", ["fallocate", "-l", `${sizeMiB}M`, path]);
if (!resized) pushAction(actions, errors, "resize-existing-swapfile-dd-fallback", ["dd", "if=/dev/zero", `of=${path}`, "bs=1M", `count=${sizeMiB}`, "status=none"]);
} else {
actions.push({ action: "reuse-existing-swapfile-path", ok: true, detail: { path, sizeBytes: existingBytes } });
}
}
pushAction(actions, errors, "chmod-600", ["chmod", "600", path]);
pushAction(actions, errors, "mkswap", ["mkswap", path]);
pushAction(actions, errors, "swapon", ["swapon", path]);
const persist = ensureFstabLine(path);
actions.push({ action: persist.action, ok: persist.ok, detail: persist.detail });
if (!persist.ok) {
errors.push({
action: persist.action,
message: "swap is active but /etc/fstab could not be updated; rerun ensure as root or add the returned fstab line manually",
detail: persist.detail,
});
}
const after = swapStatus(path);
const swapActive = after.memory.swapTotalBytes > 0;
const status = swapActive && after.fstab.persisted ? "ok" : swapActive ? "degraded" : "failed";
return {
ok: status !== "failed",
status,
requested: { path, sizeBytes },
before,
after,
actions,
errors,
} satisfies SwapEnsureResult;
}
throw new Error("server swap command must be one of: status, ensure");
}