feat: add code queue services and baidu netdisk

This commit is contained in:
Codex
2026-05-13 00:59:36 +00:00
parent ae462ed9ef
commit 6a04144d3f
63 changed files with 10983 additions and 1101 deletions
+28 -1
View File
@@ -1,4 +1,4 @@
import { existsSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { composeConfig } from "./docker";
@@ -28,6 +28,32 @@ function commandItem(name: string, command: string[]): CheckItem {
};
}
function unifiedLogRotationItem(): CheckItem {
const serviceFiles = [
"src/components/backend-core/src/index.ts",
"src/components/frontend/src/index.ts",
"src/components/provider-gateway/src/index.ts",
"src/components/microservices/code-queue/src/index.ts",
"src/components/microservices/project-manager/src/index.ts",
"src/components/microservices/baidu-netdisk/src/index.ts",
];
const offenders = serviceFiles.flatMap((path) => {
const text = readFileSync(rootPath(path), "utf8");
const directLogAppend = /appendFileSync\(\s*(?:config\.)?logFile\b/u.test(text) || /appendFileSync\(\s*process\.env\.LOG_FILE\b/u.test(text);
const missingWriter = !text.includes("createHourlyJsonlWriter");
return directLogAppend || missingWriter ? [{ path, directLogAppend, missingWriter }] : [];
});
return {
name: "logs:unified-hourly-rotation",
ok: offenders.length === 0,
detail: {
sharedWriter: "src/components/shared/src/rotating-jsonl.ts",
checkedFiles: serviceFiles,
offenders,
},
};
}
export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckItem[] } {
const items: CheckItem[] = [
{ name: "config:validated", ok: true, detail: { project: config.project.name, runtime: config.runtime } },
@@ -39,6 +65,7 @@ export function runChecks(config: UniDeskConfig): { ok: boolean; items: CheckIte
fileItem("src/components/frontend/src/index.ts"),
fileItem("src/components/provider-gateway/src/index.ts"),
fileItem("scripts/src/e2e.ts"),
unifiedLogRotationItem(),
commandItem("bun:version", ["bun", "--version"]),
commandItem("typescript:scripts", ["bunx", "tsc", "-p", "scripts/tsconfig.json", "--noEmit", "--pretty", "false"]),
commandItem("typescript:components", ["bunx", "tsc", "-p", "src/tsconfig.check.json", "--pretty", "false"]),
@@ -3,7 +3,7 @@ import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { readConfig } from "./config";
interface CodexQueuePerfOptions {
interface CodeQueuePerfOptions {
url: string;
username: string;
password: string;
@@ -40,7 +40,7 @@ function normalizeBaseUrl(value: string): string {
return value.replace(/\/+$/u, "");
}
function readOptions(): CodexQueuePerfOptions {
function readOptions(): CodeQueuePerfOptions {
const config = readConfig();
const args = process.argv.slice(2);
return {
@@ -54,7 +54,7 @@ function readOptions(): CodexQueuePerfOptions {
};
}
async function authenticateSession(context: BrowserContext, options: CodexQueuePerfOptions): Promise<void> {
async function authenticateSession(context: BrowserContext, options: CodeQueuePerfOptions): Promise<void> {
const response = await fetch(`${options.url}/login`, {
method: "POST",
headers: { "content-type": "application/json" },
@@ -89,7 +89,7 @@ function parseNumberAttr(value: string | null): number | null {
return Number.isFinite(parsed) ? parsed : null;
}
async function runCodexQueuePerf(options: CodexQueuePerfOptions): Promise<Record<string, unknown>> {
async function runCodeQueuePerf(options: CodeQueuePerfOptions): Promise<Record<string, unknown>> {
const browsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH || ".state/playwright-browsers";
const fullChromePath = resolve(browsersPath, "chromium-1217/chrome-linux64/chrome");
const launchArgs = [
@@ -105,7 +105,7 @@ async function runCodexQueuePerf(options: CodexQueuePerfOptions): Promise<Record
const apiStartedAt = new Map<Request, number>();
const apiTimings: ApiTiming[] = [];
const consoleErrors: string[] = [];
const targetUrl = `${options.url}/app/codex-queue/`;
const targetUrl = `${options.url}/app/code-queue/`;
const measuredAt = new Date().toISOString();
try {
@@ -136,10 +136,10 @@ async function runCodexQueuePerf(options: CodexQueuePerfOptions): Promise<Record
const startedAt = performance.now();
await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: options.timeoutMs });
const domContentLoadedMs = Math.round((performance.now() - startedAt) * 10) / 10;
await page.waitForSelector('[data-testid="codex-queue-page"][data-load-state="complete"]', { timeout: options.timeoutMs });
await page.waitForSelector('[data-testid="code-queue-page"][data-load-state="complete"]', { timeout: options.timeoutMs });
const completeAt = performance.now();
const dom = await page.evaluate(() => {
const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null;
const pageElement = document.querySelector('[data-testid="code-queue-page"]') as HTMLElement | null;
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
const tasks = document.querySelectorAll('[data-testid^="codex-task-codex_"]');
return {
@@ -212,7 +212,7 @@ async function runCodexQueuePerf(options: CodexQueuePerfOptions): Promise<Record
}
const options = readOptions();
const result = await runCodexQueuePerf(options);
const result = await runCodeQueuePerf(options);
if (options.json) {
console.log(JSON.stringify(result));
} else {
@@ -51,6 +51,24 @@ function asNumber(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function asBoolean(value: unknown): boolean {
return value === true || value === 1 || value === "1" || value === "true";
}
function stringList(value: unknown): string[] {
return asArray(value).map((item) => String(item ?? "")).filter((item) => item.length > 0);
}
function fmtDuration(ms: unknown): string {
const value = Number(ms);
if (!Number.isFinite(value) || value < 0) return "--";
const totalSeconds = Math.floor(value / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
if (minutes > 0) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
return `${seconds}s`;
}
function upstreamError(response: unknown): string {
const record = asRecord(response);
if (record === null) return String(response);
@@ -168,7 +186,10 @@ function compactSummary(summary: unknown, options: CodexTaskOptions, taskId: str
id: record.id ?? taskId,
queueId: record.queueId ?? null,
status: record.status ?? null,
providerId: record.providerId ?? null,
model: record.model ?? null,
agentPort: record.agentPort ?? null,
agentPortInfo: record.agentPortInfo ?? null,
reasoningEffort: record.reasoningEffort ?? null,
cwd: record.cwd ?? null,
attempts: {
@@ -177,7 +198,7 @@ function compactSummary(summary: unknown, options: CodexTaskOptions, taskId: str
currentMode: record.currentMode ?? null,
judgeFailCount: record.judgeFailCount ?? null,
judgeFailRetryLimit: record.judgeFailRetryLimit ?? null,
attemptRecords: record.attempts ?? [],
attemptRecords: asArray(record.attempts).map((attempt) => compactAttemptCycle(attempt, options.full)),
},
thread: {
codexThreadId: record.codexThreadId ?? null,
@@ -204,6 +225,7 @@ function compactSummary(summary: unknown, options: CodexTaskOptions, taskId: str
},
traceDisclosure: {
included: options.trace,
renderer: "shared trace-summary/trace-steps progressive abstraction; CLI and WebUI diverge only at final rendering",
total: record.transcriptCount ?? null,
maxSeq: transcriptMaxSeq,
defaultPage: `bun scripts/cli.ts codex task ${taskId} --trace --limit ${defaultTraceLimit}`,
@@ -216,22 +238,144 @@ function compactSummary(summary: unknown, options: CodexTaskOptions, taskId: str
};
}
function compactTracePage(body: Record<string, unknown>, taskId: string, limit: number): Record<string, unknown> {
const transcript = asArray(body.transcript);
function traceKindLabel(kind: unknown): string {
const value = String(kind || "");
if (value === "ran") return "Ran";
if (value === "explored") return "Explored";
if (value === "edited") return "Edited";
if (value === "error") return "Error";
if (value === "system") return "System";
return "Message";
}
function transcriptLineToStep(line: unknown): Record<string, unknown> {
const record = asRecord(line) ?? {};
const summaryLines = stringList(record.summaryLines);
const fallbackSummary = [record.commandPreview, record.bodyPreview, record.title].map((value) => asString(value).trim()).filter(Boolean);
return {
seq: record.seq ?? null,
at: record.at ?? null,
kind: record.kind ?? "message",
title: record.title ?? "",
status: record.status ?? null,
durationMs: record.durationMs ?? null,
rawSeqs: record.rawSeqs ?? [],
summaryLines: summaryLines.length > 0 ? summaryLines : fallbackSummary.slice(0, 4),
hasDetail: true,
};
}
function compactTraceStep(step: unknown, taskId: string): Record<string, unknown> {
const record = asRecord(step) ?? {};
const seq = record.seq ?? null;
return {
seq,
at: record.at ?? null,
kind: record.kind ?? "message",
label: traceKindLabel(record.kind),
title: record.title ?? "",
status: record.status ?? null,
durationMs: record.durationMs ?? null,
duration: fmtDuration(record.durationMs),
rawSeqs: record.rawSeqs ?? [],
summaryLines: stringList(record.summaryLines),
hasDetail: asBoolean(record.hasDetail),
detailCommand: seq === null ? null : `bun scripts/cli.ts microservice proxy code-queue /api/tasks/${encodeURIComponent(taskId)}/trace-step?seq=${encodeURIComponent(String(seq))} --raw`,
};
}
function compactAttemptCycle(value: unknown, full: boolean): Record<string, unknown> {
const record = asRecord(value) ?? {};
return {
index: record.index ?? null,
synthetic: record.synthetic ?? false,
label: record.label ?? null,
mode: record.mode ?? null,
terminalStatus: record.terminalStatus ?? null,
appServerExitCode: record.appServerExitCode ?? null,
appServerSignal: record.appServerSignal ?? null,
error: record.error ?? null,
stderrTail: textView(asString(record.stderrTail), full, 1200),
startedAt: record.startedAt ?? null,
finishedAt: record.finishedAt ?? null,
startSeq: record.startSeq ?? null,
endSeq: record.endSeq ?? null,
execution: record.execution ?? null,
finalResponse: textView(asString(record.finalResponsePreview ?? record.finalResponse), full, 3000),
judge: record.judge ?? null,
feedbackPrompt: textView(asString(record.feedbackPromptPreview), full, 1800),
};
}
function traceStepCounts(steps: Record<string, unknown>[]): Record<string, number> {
return steps.reduce<Record<string, number>>((counts, step) => {
const kind = String(step.kind || "message");
counts[kind] = (counts[kind] ?? 0) + 1;
return counts;
}, {});
}
function renderTraceConsoleRows(summary: Record<string, unknown>, steps: Record<string, unknown>[]): string[] {
const rows: string[] = [];
const execution = asRecord(summary.execution) ?? {};
const counts = traceStepCounts(steps);
rows.push([
`task=${summary.id ?? ""}`,
`status=${summary.status ?? ""}`,
`port=${summary.agentPort ?? "codex"}`,
`model=${summary.model ?? ""}`,
`updated=${summary.updatedAt ?? ""}`,
].filter((item) => !item.endsWith("=")).join(" "));
rows.push(`progressive steps=${steps.length} tools=${execution.toolCallCount ?? 0} read=${execution.readCount ?? counts.explored ?? 0} edit=${execution.editCount ?? counts.edited ?? 0} run=${execution.runCount ?? counts.ran ?? 0} errors=${counts.error ?? 0}`);
for (const step of steps) {
const head = [`#${step.seq ?? "?"}`, `[${step.label ?? traceKindLabel(step.kind)}]`, step.title ?? ""]
.filter((item) => String(item).length > 0)
.join(" ");
rows.push(`${head}${step.status ? ` (${step.status})` : ""}${step.duration && step.duration !== "--" ? ` ${step.duration}` : ""}`);
for (const line of stringList(step.summaryLines).slice(0, 4)) rows.push(` ${line}`);
}
return rows;
}
function compactProgressiveTrace(summaryBody: Record<string, unknown>, steps: Record<string, unknown>[], taskId: string, full: boolean): Record<string, unknown> {
const summary = asRecord(summaryBody.summary) ?? summaryBody;
return {
taskId: summary.id ?? taskId,
queueId: summary.queueId ?? null,
status: summary.status ?? null,
updatedAt: summary.updatedAt ?? null,
model: summary.model ?? null,
agentPort: summary.agentPort ?? null,
agentPortInfo: summary.agentPortInfo ?? null,
prompt: summary.prompt ?? null,
execution: summary.execution ?? null,
attempts: asArray(summary.attempts).map((attempt) => compactAttemptCycle(attempt, full)),
displayedStepSeqs: steps.map((step) => step.seq ?? null),
renderedRows: renderTraceConsoleRows(summary, steps),
};
}
function compactTracePage(body: Record<string, unknown>, taskId: string, limit: number, summaryBody: Record<string, unknown> | null, options: CodexTaskOptions): Record<string, unknown> {
const stepsSource = asArray(body.steps).length > 0 ? asArray(body.steps) : asArray(body.transcript).map(transcriptLineToStep);
const steps = stepsSource.map((step) => compactTraceStep(step, taskId));
const summary = summaryBody === null ? null : asRecord(summaryBody.summary) ?? summaryBody;
const nextAfterSeq = body.nextAfterSeq ?? null;
const previousBeforeSeq = body.previousBeforeSeq ?? null;
const omittedInPage = transcript.some((item) => {
const omittedInPage = steps.some((item) => {
const line = asRecord(item) ?? {};
return asNumber(line.bodyOmittedLines, 0) > 0 || asNumber(line.commandOmittedLines, 0) > 0;
});
const hasRawSeqs = steps.some((item) => asArray(item.rawSeqs).length > 0);
return {
taskId: body.taskId ?? taskId,
queueId: body.queueId ?? null,
status: body.status ?? null,
updatedAt: body.updatedAt ?? null,
agentPort: body.agentPort ?? summary?.agentPort ?? null,
agentPortInfo: body.agentPortInfo ?? summary?.agentPortInfo ?? null,
mode: body.mode ?? null,
limit,
returned: transcript.length,
returned: steps.length,
total: body.total ?? null,
maxSeq: body.maxSeq ?? null,
afterSeq: body.afterSeq ?? null,
@@ -240,13 +384,15 @@ function compactTracePage(body: Record<string, unknown>, taskId: string, limit:
previousBeforeSeq,
hasMore: body.hasMore ?? false,
hasBefore: body.hasBefore ?? false,
transcript,
steps,
progressive: summaryBody === null ? null : compactProgressiveTrace(summaryBody, steps, taskId, options.full),
commands: {
next: body.hasMore === true && nextAfterSeq !== null ? `bun scripts/cli.ts codex task ${taskId} --trace --after-seq ${nextAfterSeq} --limit ${limit}` : null,
previous: body.hasBefore === true && previousBeforeSeq !== null ? `bun scripts/cli.ts codex task ${taskId} --trace --before-seq ${previousBeforeSeq} --limit ${limit}` : null,
tail: `bun scripts/cli.ts codex task ${taskId} --trace --tail --limit ${limit}`,
first: `bun scripts/cli.ts codex task ${taskId} --trace --from-start --limit ${limit}`,
rawOutput: omittedInPage ? `Use rawSeqs on each transcript line, e.g. bun scripts/cli.ts codex output ${taskId} --after-seq <rawSeqBefore> --limit ${defaultOutputLimit}` : null,
stepDetailTemplate: `bun scripts/cli.ts microservice proxy code-queue /api/tasks/${taskId}/trace-step?seq=<seq> --raw`,
rawOutput: omittedInPage || hasRawSeqs ? `Use rawSeqs on each trace step, e.g. bun scripts/cli.ts codex output ${taskId} --after-seq <rawSeqBefore> --limit ${defaultOutputLimit}` : null,
},
};
}
@@ -294,7 +440,7 @@ function queryString(params: Record<string, string | number | boolean | null | u
}
function codexTaskSummary(taskId: string, options: CodexTaskOptions, fetcher: CodexResponseFetcher): unknown {
const summaryPath = `/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
const summaryPath = `/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
const summaryResponse = unwrapCodexResponse(fetcher(summaryPath));
const summary = summaryResponse.body.summary;
const result: Record<string, unknown> = {
@@ -307,8 +453,9 @@ function codexTaskSummary(taskId: string, options: CodexTaskOptions, fetcher: Co
if (options.traceMode === "tail") traceParams.tail = 1;
if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq;
if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq;
const traceResponse = unwrapCodexResponse(fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/transcript${queryString(traceParams)}`));
result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit);
const traceSummaryResponse = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-summary`));
const traceResponse = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-steps${queryString(traceParams)}`));
result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit, traceSummaryResponse.body, options);
}
return result;
}
@@ -353,7 +500,7 @@ function codexTaskOutput(taskId: string, options: CodexOutputOptions, fetcher: C
if (options.mode === "tail") params.tail = 1;
if (options.mode === "after") params.afterSeq = options.afterSeq;
if (options.mode === "before") params.beforeSeq = options.beforeSeq;
const response = unwrapCodexResponse(fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`));
const response = unwrapCodexResponse(fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`));
return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) };
}
@@ -366,7 +513,7 @@ export function codexOutputQuery(taskId: string, optionArgs: string[], fetcher:
}
async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions, fetcher: AsyncCodexResponseFetcher): Promise<unknown> {
const summaryPath = `/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
const summaryPath = `/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/summary${queryString({ toolLimit: options.toolLimit })}`;
const summaryResponse = unwrapCodexResponse(await fetcher(summaryPath));
const summary = summaryResponse.body.summary;
const result: Record<string, unknown> = {
@@ -379,8 +526,9 @@ async function codexTaskSummaryAsync(taskId: string, options: CodexTaskOptions,
if (options.traceMode === "tail") traceParams.tail = 1;
if (options.traceMode === "after") traceParams.afterSeq = options.afterSeq;
if (options.traceMode === "before") traceParams.beforeSeq = options.beforeSeq;
const traceResponse = unwrapCodexResponse(await fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/transcript${queryString(traceParams)}`));
result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit);
const traceSummaryResponse = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-summary`));
const traceResponse = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/trace-steps${queryString(traceParams)}`));
result.trace = compactTracePage(traceResponse.body, taskId, options.traceLimit, traceSummaryResponse.body, options);
}
return result;
}
@@ -394,7 +542,7 @@ async function codexTaskOutputAsync(taskId: string, options: CodexOutputOptions,
if (options.mode === "tail") params.tail = 1;
if (options.mode === "after") params.afterSeq = options.afterSeq;
if (options.mode === "before") params.beforeSeq = options.beforeSeq;
const response = unwrapCodexResponse(await fetcher(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`));
const response = unwrapCodexResponse(await fetcher(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/output${queryString(params)}`));
return { upstream: response.upstream, outputPage: compactOutputPage(response.body, taskId, options.limit) };
}
@@ -413,19 +561,19 @@ function requireQueueId(args: string[], command: string): string {
return raw.trim();
}
function codexQueues(): unknown {
return unwrapCodexResponse(coreInternalFetch("/api/microservices/codex-queue/proxy/api/queues"));
function codeQueues(): unknown {
return unwrapCodexResponse(coreInternalFetch("/api/microservices/code-queue/proxy/api/queues"));
}
function codexCreateQueue(queueId: string): unknown {
return unwrapCodexResponse(coreInternalFetch("/api/microservices/codex-queue/proxy/api/queues", { method: "POST", body: { queueId } }));
return unwrapCodexResponse(coreInternalFetch("/api/microservices/code-queue/proxy/api/queues", { method: "POST", body: { queueId } }));
}
function codexMoveTask(taskId: string, queueId: string): unknown {
return unwrapCodexResponse(coreInternalFetch(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/move`, { method: "POST", body: { queueId } }));
return unwrapCodexResponse(coreInternalFetch(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(taskId)}/move`, { method: "POST", body: { queueId } }));
}
export async function runCodexQueueCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
export async function runCodeQueueCommand(_config: UniDeskConfig, args: string[]): Promise<unknown> {
const [action = "task", taskIdArg] = args;
if (action === "task" || action === "summary" || action === "show") {
const taskId = requireTaskId(taskIdArg, `codex ${action}`);
@@ -435,11 +583,11 @@ export async function runCodexQueueCommand(_config: UniDeskConfig, args: string[
const taskId = requireTaskId(taskIdArg, "codex output");
return codexOutputQuery(taskId, args.slice(2));
}
if (action === "queues") return codexQueues();
if (action === "queues") return codeQueues();
if (action === "queue") {
const sub = taskIdArg ?? "list";
if (sub === "list") return codexQueues();
if (sub === "create") return codexCreateQueue(requireQueueId(args.slice(2), "codex queue create"));
if (sub === "list") return codeQueues();
if (sub === "create") return codexCreateQueue(requireQueueId(args.slice(2), "queue create"));
}
if (action === "move") {
const taskId = requireTaskId(taskIdArg, "codex move");
+45 -25
View File
@@ -1,5 +1,5 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { basename, join, resolve } from "node:path";
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";
@@ -7,6 +7,7 @@ import { startJob } from "./jobs";
export interface ComposeRuntimeEnv {
envFile: string;
logDir: string;
logDay: string;
logPrefix: string;
}
@@ -18,7 +19,7 @@ export interface ContainerStatus {
ports: string;
}
const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "codex-queue", "project-manager"] as const;
const rebuildableServices = ["backend-core", "frontend", "provider-gateway", "todo-note", "code-queue", "project-manager", "baidu-netdisk"] as const;
export type RebuildableService = typeof rebuildableServices[number];
export function isRebuildableService(value: string | undefined): value is RebuildableService {
@@ -58,18 +59,25 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
const previousRaw = existsSync(envFile) ? readFileSync(envFile, "utf8") : "";
const previousValue = (key: string): string => previousRaw.match(new RegExp(`^${key}=(.*)$`, "m"))?.[1]?.replace(/^"|"$/g, "") ?? "";
const runtimeSecret = (key: string): string => process.env[key] ?? previousValue(key);
let logDir: string;
let logRoot: string;
let logDay: string;
let logPrefix: string;
if (!freshLogPrefix && previousRaw.length > 0) {
logDir = previousValue("UNIDESK_LOG_DIR") || rootPath(config.paths.logsDir);
const previousLogDir = previousValue("UNIDESK_LOG_DIR");
const previousLogRoot = previousLogDir && /^\d{8}$/u.test(basename(previousLogDir)) ? dirname(previousLogDir) : previousLogDir;
logRoot = previousLogRoot || rootPath(config.paths.logsDir);
logPrefix = previousValue("UNIDESK_LOG_PREFIX") || localDateParts(new Date()).stamp;
logDay = previousValue("UNIDESK_LOG_DAY") || logPrefix.match(/^\d{8}/u)?.[0] || localDateParts(new Date()).day;
} else {
const parts = localDateParts(new Date());
logDir = resolve(rootPath(config.paths.logsDir, parts.day));
logRoot = resolve(rootPath(config.paths.logsDir));
logDay = parts.day;
logPrefix = parts.stamp;
}
mkdirSync(logDir, { recursive: true });
chmodSync(logDir, 0o777);
logRoot = resolve(logRoot);
mkdirSync(join(logRoot, logDay), { recursive: true });
chmodSync(logRoot, 0o777);
chmodSync(join(logRoot, logDay), 0o777);
const labels = JSON.stringify(config.providerGateway.labels);
const microservices = JSON.stringify(config.microservices);
const lines = {
@@ -105,8 +113,10 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_PROVIDER_UPGRADE_COMPOSE_PROJECT: config.providerGateway.upgrade.composeProject,
UNIDESK_PROVIDER_UPGRADE_SERVICE: config.providerGateway.upgrade.service,
UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE: config.providerGateway.upgrade.runnerImage,
UNIDESK_LOG_DIR: logDir,
UNIDESK_LOG_DIR: logRoot,
UNIDESK_LOG_DAY: logDay,
UNIDESK_LOG_PREFIX: logPrefix,
UNIDESK_LOG_RETENTION_BYTES: runtimeSecret("UNIDESK_LOG_RETENTION_BYTES") || "1GiB",
UNIDESK_HOST_ROOT_SSH_DIR: process.env.UNIDESK_HOST_ROOT_SSH_DIR || "/root/.ssh",
UNIDESK_HOST_SSH_KEY_DIR: config.sshForwarding.keyDir,
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
@@ -121,23 +131,32 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_SCAN_INTERVAL_MS") || "30000",
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_TIMEOUT_MS") || "15000",
UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_TODO_NOTE_REMINDER_CLAUDEQQ_SEND_ATTEMPTS") || "3",
UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_KEY"),
UNIDESK_CODEX_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_MODEL") || "MiniMax-M2.7",
UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_API_BASE") || "https://api.minimaxi.com/v1",
UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS") || "60000",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_ENABLED") || "true",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE") || "private",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_USER_ID") || "645275593",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID"),
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS") || "12000",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS") || "15000",
UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_CODEX_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS") || "3",
UNIDESK_CODE_QUEUE_MINIMAX_API_KEY: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_API_KEY") || runtimeSecret("MINIMAX_API_KEY"),
UNIDESK_CODE_QUEUE_MINIMAX_MODEL: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_MODEL") || runtimeSecret("MINIMAX_MODEL") || "MiniMax-M2.7",
UNIDESK_CODE_QUEUE_MINIMAX_API_BASE: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_API_BASE") || runtimeSecret("MINIMAX_API_BASE") || "https://api.minimaxi.com/v1",
UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS: runtimeSecret("UNIDESK_CODE_QUEUE_MINIMAX_JUDGE_TIMEOUT_MS") || "60000",
UNIDESK_CODE_QUEUE_REMOTE_WORKDIR: runtimeSecret("UNIDESK_CODE_QUEUE_REMOTE_WORKDIR") || "/home/ubuntu",
UNIDESK_CODE_QUEUE_EXECUTION_PROVIDER_IDS: runtimeSecret("UNIDESK_CODE_QUEUE_EXECUTION_PROVIDER_IDS") || "D601",
UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_DEFAULT_PROVIDER_ID") || "D601",
UNIDESK_CODE_QUEUE_DEV_CONTAINER_IMAGE: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_IMAGE"),
UNIDESK_CODE_QUEUE_DEV_CONTAINER_WORKDIR: runtimeSecret("UNIDESK_CODE_QUEUE_DEV_CONTAINER_WORKDIR") || "/home/ubuntu",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_ENABLED") || "true",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_BASE_URL") || "http://backend-core:8080/api/microservices/claudeqq/proxy",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TARGET_TYPE") || "private",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_USER_ID") || "645275593",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_GROUP_ID"),
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_MAX_RESPONSE_CHARS") || "12000",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_TIMEOUT_MS") || "15000",
UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS: runtimeSecret("UNIDESK_CODE_QUEUE_NOTIFY_CLAUDEQQ_SEND_ATTEMPTS") || "3",
UNIDESK_BAIDU_NETDISK_CLIENT_ID: runtimeSecret("UNIDESK_BAIDU_NETDISK_CLIENT_ID"),
UNIDESK_BAIDU_NETDISK_CLIENT_SECRET: runtimeSecret("UNIDESK_BAIDU_NETDISK_CLIENT_SECRET"),
UNIDESK_BAIDU_NETDISK_TOKEN_KEY: runtimeSecret("UNIDESK_BAIDU_NETDISK_TOKEN_KEY"),
UNIDESK_BAIDU_NETDISK_APP_ROOT: runtimeSecret("UNIDESK_BAIDU_NETDISK_APP_ROOT") || "/apps/UniDeskBaiduNetdisk",
OPENAI_API_KEY: runtimeSecret("OPENAI_API_KEY"),
CRS_OAI_KEY: runtimeSecret("CRS_OAI_KEY"),
};
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
return { envFile, logDir, logPrefix };
return { envFile, logDir: logRoot, logDay, logPrefix };
}
export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType<typeof runCommand> } {
@@ -226,7 +245,7 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic
validateScript,
].join("\n");
const command = ["bash", "-lc", composeLockedScript(script)];
const jobRunner = service === "codex-queue" ? { runner: "docker" as const, dockerImage: "unidesk-codex-queue:latest" } : {};
const jobRunner = service === "code-queue" ? { runner: "docker" as const, dockerImage: "unidesk-code-queue:latest" } : {};
const job = startJob("server_rebuild", command, `Rebuild and validate UniDesk ${service} with serialized Docker Compose mutation`, jobRunner);
return {
job,
@@ -242,7 +261,7 @@ export function rebuildService(config: UniDeskConfig, service: RebuildableServic
noDeps: true,
forceRecreate: true,
composeMutationLock: rootPath(".state", "locks", "server-compose.lock"),
jobRunner: service === "codex-queue" ? "docker" : "local",
jobRunner: service === "code-queue" ? "docker" : "local",
postUpValidation: true,
namedVolumesPreserved: true,
},
@@ -352,8 +371,9 @@ export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
internalPorts: [
{ name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null },
{ name: "database", containerPort: config.network.database.containerPort, hostPort: null },
{ name: "codex-queue", containerPort: 4222, hostPort: null },
{ name: "code-queue", containerPort: 4222, hostPort: null },
{ name: "project-manager", containerPort: 4233, hostPort: null },
{ name: "baidu-netdisk", containerPort: 4244, hostPort: null },
],
containers: dockerContainers(config),
health: {
@@ -391,7 +411,7 @@ export function stackLogs(config: UniDeskConfig, tailBytes: number): unknown {
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 containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "codex-queue-backend", "project-manager-backend"];
const containerNames = ["unidesk-database", "unidesk-backend-core", "unidesk-frontend", "unidesk-provider-gateway-main", "todo-note-backend", "code-queue-backend", "project-manager-backend", "baidu-netdisk-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) };
+469 -127
View File
@@ -37,7 +37,8 @@ const NETWORK_CHECK_NAMES = [
"network:met-nonlinear-public-blocked",
"network:claudeqq-public-blocked",
"network:todo-note-public-blocked",
"network:codex-queue-public-blocked",
"network:code-queue-public-blocked",
"network:filebrowser-public-blocked",
] as const;
const SERVICE_CHECK_NAMES = [
@@ -49,6 +50,7 @@ const SERVICE_CHECK_NAMES = [
"provider:system-status",
"provider:process-resource-status",
"provider:docker-status",
"provider:gateway-restart-policy",
"provider:upgrade-plan",
"provider-ingress:public-health",
"microservice:catalog-findjob",
@@ -56,7 +58,11 @@ const SERVICE_CHECK_NAMES = [
"microservice:catalog-met-nonlinear",
"microservice:catalog-claudeqq",
"microservice:catalog-todo-note",
"microservice:catalog-codex-queue",
"microservice:catalog-code-queue",
"microservice:catalog-filebrowser",
"microservice:filebrowser-health",
"microservice:filebrowser-webui",
"microservice:filebrowser-d601-health",
"microservice:findjob-status",
"microservice:findjob-health",
"microservice:findjob-summary",
@@ -79,9 +85,9 @@ const SERVICE_CHECK_NAMES = [
"microservice:todo-note-health",
"microservice:todo-note-migrated-data",
"microservice:todo-note-write-path",
"microservice:codex-queue-status",
"microservice:codex-queue-health",
"microservice:codex-queue-tasks",
"microservice:code-queue-status",
"microservice:code-queue-health",
"microservice:code-queue-tasks",
] as const;
const DATABASE_CHECK_NAMES = [
@@ -112,10 +118,12 @@ const FRONTEND_CHECK_NAMES = [
"frontend:microservice-catalog-visible",
"frontend:todo-note-integrated-visible",
"frontend:findjob-integrated-visible",
"frontend:codex-queue-integrated-visible",
"frontend:codex-queue-initial-prompt-full-expand",
"frontend:codex-queue-trace-full-load",
"frontend:codex-queue-judge-wrap",
"frontend:code-queue-integrated-visible",
"frontend:code-queue-summary-mobile-wrap",
"frontend:code-queue-initial-prompt-full-expand",
"frontend:code-queue-trace-full-load",
"frontend:code-queue-judge-wrap",
"frontend:code-queue-error-red-markers",
"frontend:claudeqq-integrated-visible",
"frontend:url-route-deeplink",
"frontend:pipeline-integrated-visible",
@@ -269,13 +277,18 @@ type OverflowProbe = {
ok: boolean;
documentOverflowX: number;
offenders: Array<{
overflowKind: string;
overflowPx: number;
tag: string;
selector: string;
className: string;
testId: string;
text: string;
left: number;
right: number;
width: number;
clientWidth: number;
scrollWidth: number;
parentClassName: string;
}>;
};
@@ -294,7 +307,7 @@ const LAYOUT_OVERFLOW_PAGE_TEST_IDS: Record<string, string> = {
"/app/pipeline/": "pipeline-page",
"/app/met-nonlinear/": "met-nonlinear-page",
"/app/claudeqq/": "claudeqq-page",
"/app/codex-queue/": "codex-queue-page",
"/app/code-queue/": "code-queue-page",
};
function layoutOverflowTargets(): Array<{ label: string; path: string; testId: string }> {
@@ -315,42 +328,94 @@ async function collectLayoutOverflow(page: Page, viewport: { width: number; heig
for (const target of targets) {
await page.goto(`${origin}${target.path}`, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForSelector(`[data-testid="${target.testId}"]`, { timeout: 15000 }).catch(() => undefined);
await page.waitForTimeout(160);
await page.waitForLoadState("networkidle", { timeout: 1200 }).catch(() => undefined);
await page.waitForTimeout(180);
const result = await page.evaluate(({ viewportName: currentViewport, label, path, testId }) => {
const overflowTolerancePx = 6;
const viewportWidth = document.documentElement.clientWidth;
const checkNestedContainerOverflow = currentViewport === "mobile" || viewportWidth <= 600;
const documentOverflowX = Math.max(0, document.documentElement.scrollWidth - viewportWidth, document.body.scrollWidth - viewportWidth);
const allowed = (element: Element): boolean => {
const html = element as HTMLElement;
if (html.closest(".raw-dialog, .raw-json, .performance-table-wrap, .process-table-wrap, .docker-table-wrap, .table-wrap, .met-project-table, .pipeline-flow-frame, .react-flow, .codex-transcript, .codex-task-list, .todo-tree-scroll, .tabs, .rail")) return true;
const style = getComputedStyle(html);
return ["auto", "scroll"].includes(style.overflowX);
const intentionalHorizontalScrollSelector = [
".raw-json",
".performance-table-wrap",
".process-table-wrap",
".docker-table-wrap",
".table-wrap",
".met-project-table",
".pipeline-flow-frame",
".react-flow",
".pipeline-gantt-viewport",
".codex-edit-diff",
".tabs",
".rail",
".status-strip",
".top-status-bar",
".codex-transcript-item.ran .codex-transcript-command",
".codex-transcript-item.ran .codex-transcript-body",
".codex-transcript-item.explored .codex-transcript-command",
".codex-transcript-item.explored .codex-transcript-body",
".codex-transcript-item.edited .codex-transcript-command",
".codex-transcript-item.edited .codex-transcript-body",
].join(",");
const insideIntentionalHorizontalScroll = (element: Element): boolean => Boolean(element.closest(intentionalHorizontalScrollSelector));
const selectorFor = (html: HTMLElement): string => {
const tag = html.tagName.toLowerCase();
const test = html.getAttribute("data-testid");
const id = html.id ? `#${html.id}` : "";
const className = String(html.className || "");
const classes = className
.split(/\s+/)
.map((item) => item.trim())
.filter(Boolean)
.slice(0, 3)
.map((item) => `.${item}`)
.join("");
return `${tag}${id}${classes}${test ? `[data-testid="${test}"]` : ""}`;
};
const offenders = Array.from(document.body.querySelectorAll("*")).flatMap((element) => {
const html = element as HTMLElement;
if (!(html instanceof HTMLElement) || allowed(html)) return [];
const offenderFor = (html: HTMLElement, overflowKind: string, overflowPx: number) => {
const rect = html.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return [];
const excessLeft = rect.left < -2;
const excessRight = rect.right > viewportWidth + 2;
if (!excessLeft && !excessRight) return [];
const parent = html.parentElement;
return [{
return {
overflowKind,
overflowPx: Math.round(overflowPx),
tag: html.tagName.toLowerCase(),
selector: selectorFor(html).slice(0, 180),
className: String(html.className || "").slice(0, 160),
testId: html.getAttribute("data-testid") || "",
text: (html.innerText || html.textContent || "").replace(/\s+/g, " ").trim().slice(0, 160),
left: Math.round(rect.left),
right: Math.round(rect.right),
width: Math.round(rect.width),
clientWidth: Math.round(html.clientWidth),
scrollWidth: Math.round(html.scrollWidth),
parentClassName: String(parent?.className || "").slice(0, 160),
}];
}).slice(0, 20);
};
};
const offenders = Array.from(document.body.querySelectorAll("*")).flatMap((element) => {
const html = element as HTMLElement;
if (!(html instanceof HTMLElement) || insideIntentionalHorizontalScroll(html)) return [];
const style = getComputedStyle(html);
const rect = html.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return [];
const excessLeft = rect.left < -overflowTolerancePx;
const excessRight = rect.right > viewportWidth + overflowTolerancePx;
const viewportOverflowPx = Math.max(0, -rect.left, rect.right - viewportWidth);
const containerOverflowPx = Math.max(0, html.scrollWidth - html.clientWidth);
const formControl = ["INPUT", "SELECT", "TEXTAREA"].includes(html.tagName);
const clippedOverflow = ["clip", "hidden"].includes(style.overflowX);
const found: ReturnType<typeof offenderFor>[] = [];
if (excessLeft || excessRight) found.push(offenderFor(html, "viewport", viewportOverflowPx));
if (checkNestedContainerOverflow && !formControl && !clippedOverflow && html.clientWidth > 0 && containerOverflowPx > overflowTolerancePx) found.push(offenderFor(html, "container", containerOverflowPx));
return found;
})
.sort((a, b) => b.overflowPx - a.overflowPx)
.slice(0, 20);
return {
viewport: currentViewport,
label,
path,
testId,
ok: documentOverflowX <= 2 && offenders.length === 0,
ok: documentOverflowX <= overflowTolerancePx && offenders.length === 0,
documentOverflowX: Math.round(documentOverflowX),
offenders,
};
@@ -620,7 +685,7 @@ function dockerPortSummary(): unknown {
}
function dockerStatusCheckDetail(dockerStatus: unknown, providerId: string): unknown {
const response = dockerStatus as { ok?: boolean; status?: number; body?: { dockerStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; dockerStatus?: { ok?: boolean; socketPresent?: boolean; collectedAt?: string; counts?: unknown; daemon?: unknown; containers?: Array<{ id?: string; name?: string; image?: string; state?: string; status?: string; ports?: string }> } }> } };
const response = dockerStatus as { ok?: boolean; status?: number; body?: { dockerStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; dockerStatus?: { ok?: boolean; socketPresent?: boolean; collectedAt?: string; counts?: unknown; daemon?: unknown; containers?: Array<{ id?: string; name?: string; image?: string; state?: string; status?: string; ports?: string; restartPolicy?: string; pidMode?: string }> } }> } };
const item = response.body?.dockerStatuses?.find((entry) => entry.providerId === providerId);
return {
ok: response.ok,
@@ -641,11 +706,57 @@ function dockerStatusCheckDetail(dockerStatus: unknown, providerId: string): unk
state: container.state,
status: container.status,
ports: container.ports,
restartPolicy: container.restartPolicy,
pidMode: container.pidMode,
})),
},
};
}
function providerGatewayRuntimeGuardDetail(dockerStatus: unknown): { ok: boolean; gateways: unknown[]; violations: unknown[] } {
const response = dockerStatus as {
body?: {
dockerStatuses?: Array<{
providerId?: string;
nodeStatus?: string;
dockerStatus?: {
containers?: Array<{
id?: string;
name?: string;
image?: string;
state?: string;
restartPolicy?: string;
pidMode?: string;
composeService?: string;
composeProject?: string;
}>;
};
}>;
};
};
const gateways = (response.body?.dockerStatuses ?? []).flatMap((entry) =>
(entry.dockerStatus?.containers ?? [])
.filter((container) => String(container.name || "").includes("provider-gateway") || container.composeService === "provider-gateway")
.map((container) => ({
providerId: entry.providerId,
nodeStatus: entry.nodeStatus,
id: container.id,
name: container.name,
image: container.image,
state: container.state,
restartPolicy: container.restartPolicy,
pidMode: container.pidMode,
composeProject: container.composeProject,
composeService: container.composeService,
}))
);
const violations = gateways.filter((container) =>
(container as { state?: unknown }).state === "running"
&& ((container as { restartPolicy?: unknown }).restartPolicy !== "always" || (container as { pidMode?: unknown }).pidMode !== "host")
);
return { ok: gateways.length > 0 && violations.length === 0, gateways, violations };
}
function systemStatusCheckDetail(systemStatus: unknown, providerId: string): unknown {
const response = systemStatus as { ok?: boolean; status?: number; body?: { systemStatuses?: Array<{ providerId?: string; name?: string; nodeStatus?: string; updatedAt?: string; current?: { ok?: boolean; collectedAt?: string; cpu?: unknown; memory?: unknown; disk?: unknown }; history?: unknown[] }> } };
const item = response.body?.systemStatuses?.find((entry) => entry.providerId === providerId);
@@ -676,7 +787,8 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
const metNonlinearPublic = await fetchProbe(`http://${config.network.publicHost}:3288/health`, 2500);
const claudeqqPublic = await fetchProbe(`http://${config.network.publicHost}:3290/health`, 2500);
const todoNotePublic = await fetchProbe(`http://${config.network.publicHost}:4211/api/health`, 2500);
const codexQueuePublic = await fetchProbe(`http://${config.network.publicHost}:14222/health`, 2500);
const codeQueuePublic = await fetchProbe(`http://${config.network.publicHost}:14222/health`, 2500);
const filebrowserPublic = await fetchProbe(`http://${config.network.publicHost}:4251/health`, 2500);
addSelectedCheck(checks, options, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`) && !portsText.includes(":14222->"), portSummary);
addSelectedCheck(checks, options, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
addSelectedCheck(checks, options, "network:database-public-blocked", (databasePublic as { reachable?: boolean }).reachable === false, databasePublic);
@@ -684,7 +796,8 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
addSelectedCheck(checks, options, "network:met-nonlinear-public-blocked", (metNonlinearPublic as { reachable?: boolean }).reachable === false, metNonlinearPublic);
addSelectedCheck(checks, options, "network:claudeqq-public-blocked", (claudeqqPublic as { reachable?: boolean }).reachable === false, claudeqqPublic);
addSelectedCheck(checks, options, "network:todo-note-public-blocked", (todoNotePublic as { reachable?: boolean }).reachable === false, todoNotePublic);
addSelectedCheck(checks, options, "network:codex-queue-public-blocked", (codexQueuePublic as { reachable?: boolean }).reachable === false, codexQueuePublic);
addSelectedCheck(checks, options, "network:code-queue-public-blocked", (codeQueuePublic as { reachable?: boolean }).reachable === false, codeQueuePublic);
addSelectedCheck(checks, options, "network:filebrowser-public-blocked", (filebrowserPublic as { reachable?: boolean }).reachable === false, filebrowserPublic);
}
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[], options: E2ERunOptions): Promise<void> {
@@ -715,9 +828,12 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const todoNoteStatus = dockerCoreJson("/api/microservices/todo-note/status");
const todoNoteHealth = dockerCoreJson("/api/microservices/todo-note/health");
const todoNoteInstances = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances");
const codexQueueStatus = dockerCoreJson("/api/microservices/codex-queue/status");
const codexQueueHealth = dockerCoreJson("/api/microservices/codex-queue/health");
const codexQueueTasks = dockerCoreJson("/api/microservices/codex-queue/proxy/api/tasks?limit=5");
const codeQueueStatus = dockerCoreJson("/api/microservices/code-queue/status");
const codeQueueHealth = dockerCoreJson("/api/microservices/code-queue/health");
const codeQueueTasks = dockerCoreJson("/api/microservices/code-queue/proxy/api/tasks?limit=5");
const filebrowserHealth = dockerCoreJson("/api/microservices/filebrowser/health");
const filebrowserWebui = dockerCoreJson("/api/microservices/filebrowser/proxy/");
const filebrowserD601Health = dockerCoreJson("/api/microservices/filebrowser-d601/health");
const todoE2eName = `E2E Todo ${Date.now()}`;
const todoNoteCreate = dockerCoreJson("/api/microservices/todo-note/proxy/api/instances", { method: "POST", body: { name: todoE2eName } });
const todoCreatedId = (todoNoteCreate as { body?: { id?: string } }).body?.id ?? "";
@@ -737,7 +853,7 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
: { ok: false, error: "missing created todo note id" };
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number; pgdata?: { volumeName?: string; databaseBytes?: number } } }).body;
const corePerformanceBody = (corePerformance as { body?: { ok?: boolean; requests?: { componentSummary?: unknown[] }; operations?: { summary?: unknown[] }; database?: { pgdata?: { volumeName?: string }; codexQueueStorage?: { table?: string } } } }).body;
const corePerformanceBody = (corePerformance as { body?: { ok?: boolean; requests?: { componentSummary?: unknown[] }; operations?: { summary?: unknown[] }; database?: { pgdata?: { volumeName?: string }; codeQueueStorage?: { table?: string } } } }).body;
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string; labels?: Record<string, unknown> }> } }).body?.nodes ?? [];
const mainNode = nodeList.find((node) => node.providerId === config.providerGateway.id);
const expectedGatewayVersion = providerGatewayPackageVersion();
@@ -747,21 +863,25 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const processMemoryDescending = mainProcesses.length < 2 || mainProcesses.every((row, index, rows) => index === 0 || Number(rows[index - 1]?.rssBytes ?? 0) >= Number(row.rssBytes ?? 0));
const dockerStatuses = (dockerStatus as { body?: { dockerStatuses?: Array<{ providerId?: string; dockerStatus?: { counts?: { containers?: number }; containers?: unknown[] } }> } }).body?.dockerStatuses ?? [];
const mainDocker = dockerStatuses.find((item) => item.providerId === config.providerGateway.id);
const providerGatewayRuntimeGuard = providerGatewayRuntimeGuardDetail(dockerStatus);
addSelectedCheck(checks, options, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview);
addSelectedCheck(checks, options, "core:pgdata-usage", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.pgdata?.volumeName === config.database.volume && Number(overviewBody.pgdata.databaseBytes ?? 0) > 0, coreOverview);
addSelectedCheck(checks, options, "core:performance-api", (corePerformance as { ok?: boolean }).ok === true && corePerformanceBody?.ok === true && Array.isArray(corePerformanceBody.requests?.componentSummary) && Array.isArray(corePerformanceBody.operations?.summary) && corePerformanceBody.database?.pgdata?.volumeName === config.database.volume && corePerformanceBody.database?.codexQueueStorage?.table === "unidesk_codex_queue_tasks", corePerformance);
addSelectedCheck(checks, options, "core:performance-api", (corePerformance as { ok?: boolean }).ok === true && corePerformanceBody?.ok === true && Array.isArray(corePerformanceBody.requests?.componentSummary) && Array.isArray(corePerformanceBody.operations?.summary) && corePerformanceBody.database?.pgdata?.volumeName === config.database.volume && corePerformanceBody.database?.codeQueueStorage?.table === "unidesk_code_queue_tasks", corePerformance);
addSelectedCheck(checks, options, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes);
addSelectedCheck(checks, options, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null });
addSelectedCheck(checks, options, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
addSelectedCheck(checks, options, "provider:process-resource-status", mainProcesses.length > 0 && mainSystem?.current?.processSummary?.defaultSort === "memory_desc" && processMemoryDescending && mainProcesses.some((row) => Number.isFinite(row.pid) && Number.isFinite(row.rssBytes) && Number.isFinite(row.cpuPercent) && typeof row.command === "string"), { providerId: config.providerGateway.id, processSummary: mainSystem?.current?.processSummary, sample: mainProcesses.slice(0, 5) });
addSelectedCheck(checks, options, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id));
addSelectedCheck(checks, options, "provider:gateway-restart-policy", providerGatewayRuntimeGuard.ok, providerGatewayRuntimeGuard);
const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? [];
const findjob = microserviceList.find((service) => service.id === "findjob");
const pipeline = microserviceList.find((service) => service.id === "pipeline");
const metNonlinear = microserviceList.find((service) => service.id === "met-nonlinear");
const claudeqq = microserviceList.find((service) => service.id === "claudeqq");
const todoNote = microserviceList.find((service) => service.id === "todo-note");
const codexQueue = microserviceList.find((service) => service.id === "codex-queue");
const codeQueue = microserviceList.find((service) => service.id === "code-queue");
const filebrowser = microserviceList.find((service) => service.id === "filebrowser");
const filebrowserD601 = microserviceList.find((service) => service.id === "filebrowser-d601");
const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body;
const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body;
const todoNoteRows = (todoNoteInstances as { body?: { instances?: Array<{ id?: string; name?: string; todoCount?: number; completedCount?: number }> } }).body?.instances ?? [];
@@ -776,8 +896,11 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const claudeqqNapcatLoginBody = (claudeqqNapcatLogin as { body?: { ok?: boolean; napcat?: { containerized?: boolean; loginState?: string; qrcode?: { available?: boolean; dataUrl?: string } }; login?: { ready?: boolean; state?: string } } }).body;
const claudeqqEventsBody = (claudeqqEvents as { body?: { ok?: boolean; events?: unknown[]; count?: number } }).body;
const claudeqqSubscriptionsBody = (claudeqqSubscriptions as { body?: { ok?: boolean; subscriptions?: unknown[]; count?: number } }).body;
const codexQueueHealthBody = (codexQueueHealth as { body?: { ok?: boolean; queue?: { defaultModel?: string; judgeConfigured?: boolean; modelReasoningEfforts?: Record<string, string> } } }).body;
const codexQueueTasksBody = (codexQueueTasks as { body?: { ok?: boolean; queue?: { defaultModel?: string; modelReasoningEfforts?: Record<string, string> }; tasks?: unknown[] } }).body;
const codeQueueHealthBody = (codeQueueHealth as { body?: { ok?: boolean; queue?: { defaultModel?: string; judgeConfigured?: boolean; modelReasoningEfforts?: Record<string, string> } } }).body;
const codeQueueTasksBody = (codeQueueTasks as { body?: { ok?: boolean; queue?: { defaultModel?: string; modelReasoningEfforts?: Record<string, string> }; tasks?: unknown[] } }).body;
const filebrowserHealthBody = (filebrowserHealth as { body?: { status?: string } }).body;
const filebrowserD601HealthBody = (filebrowserD601Health as { body?: { status?: string } }).body;
const filebrowserWebuiText = String((filebrowserWebui as { body?: { text?: string } }).body?.text || "");
const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs)
? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined
: undefined;
@@ -804,7 +927,16 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
addSelectedCheck(checks, options, "microservice:catalog-met-nonlinear", (microservices as { ok?: boolean }).ok === true && metNonlinear?.providerId === "D601" && metNonlinear.backend?.public === false && metNonlinear.runtime?.container?.name === "met-nonlinear-ts", { microservices });
addSelectedCheck(checks, options, "microservice:catalog-claudeqq", (microservices as { ok?: boolean }).ok === true && claudeqq?.providerId === "D601" && claudeqq.backend?.public === false && claudeqq.runtime?.container?.name === "claudeqq-backend", { microservices });
addSelectedCheck(checks, options, "microservice:catalog-todo-note", (microservices as { ok?: boolean }).ok === true && todoNote?.providerId === config.providerGateway.id && todoNote.backend?.public === false && todoNote.runtime?.container?.name === "todo-note-backend", { microservices });
addSelectedCheck(checks, options, "microservice:catalog-codex-queue", (microservices as { ok?: boolean }).ok === true && codexQueue?.providerId === config.providerGateway.id && codexQueue.backend?.public === false && codexQueue.runtime?.container?.name === "codex-queue-backend", { microservices });
addSelectedCheck(checks, options, "microservice:catalog-code-queue", (microservices as { ok?: boolean }).ok === true && codeQueue?.providerId === config.providerGateway.id && codeQueue.backend?.public === false && codeQueue.runtime?.container?.name === "code-queue-backend", { microservices });
addSelectedCheck(checks, options, "microservice:catalog-filebrowser", (microservices as { ok?: boolean }).ok === true
&& filebrowser?.providerId === "D518"
&& filebrowser.backend?.public === false
&& filebrowser.runtime?.container?.name === "unidesk-filebrowser-d518"
&& filebrowserD601?.providerId === "D601",
{ filebrowser, filebrowserD601 });
addSelectedCheck(checks, options, "microservice:filebrowser-health", (filebrowserHealth as { ok?: boolean }).ok === true && filebrowserHealthBody?.status === "OK", filebrowserHealth);
addSelectedCheck(checks, options, "microservice:filebrowser-webui", (filebrowserWebui as { ok?: boolean; status?: number }).ok === true && (filebrowserWebui as { status?: number }).status === 200 && filebrowserWebuiText.includes("File Browser"), { status: (filebrowserWebui as { status?: number }).status, textPreview: filebrowserWebuiText.slice(0, 600) });
addSelectedCheck(checks, options, "microservice:filebrowser-d601-health", (filebrowserD601Health as { ok?: boolean }).ok === true && filebrowserD601HealthBody?.status === "OK", filebrowserD601Health);
addSelectedCheck(checks, options, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus);
addSelectedCheck(checks, options, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth);
addSelectedCheck(checks, options, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary);
@@ -847,9 +979,9 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
addSelectedCheck(checks, options, "microservice:todo-note-health", (todoNoteHealth as { ok?: boolean; body?: { ok?: boolean; storage?: string } }).ok === true && (todoNoteHealth as { body?: { ok?: boolean; storage?: string } }).body?.ok === true && (todoNoteHealth as { body?: { storage?: string } }).body?.storage === "postgres", todoNoteHealth);
addSelectedCheck(checks, options, "microservice:todo-note-migrated-data", (todoNoteInstances as { ok?: boolean }).ok === true && todoNoteRows.length >= 5 && ["CONSTAR", "大论文", "找工作", "小论文", "事务"].every((name) => todoNoteNames.includes(name)) && todoNoteRows.reduce((sum, row) => sum + Number(row.todoCount ?? 0), 0) >= 100, { todoNoteInstances });
addSelectedCheck(checks, options, "microservice:todo-note-write-path", (todoNoteCreate as { ok?: boolean }).ok === true && (todoNoteAdd as { ok?: boolean }).ok === true && (todoNoteToggle as { ok?: boolean }).ok === true && (todoNoteUndo as { ok?: boolean }).ok === true && (todoNoteDelete as { ok?: boolean }).ok === true, { todoNoteCreate, todoNoteAdd, todoNoteToggle, todoNoteUndo, todoNoteDelete });
addSelectedCheck(checks, options, "microservice:codex-queue-status", (codexQueueStatus as { ok?: boolean }).ok === true && (codexQueueStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, codexQueueStatus);
addSelectedCheck(checks, options, "microservice:codex-queue-health", (codexQueueHealth as { ok?: boolean }).ok === true && codexQueueHealthBody?.ok === true && codexQueueHealthBody.queue?.defaultModel === "gpt-5.5" && codexQueueHealthBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codexQueueHealth);
addSelectedCheck(checks, options, "microservice:codex-queue-tasks", (codexQueueTasks as { ok?: boolean }).ok === true && codexQueueTasksBody?.ok === true && Array.isArray(codexQueueTasksBody.tasks) && codexQueueTasksBody.queue?.defaultModel === "gpt-5.5" && codexQueueTasksBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codexQueueTasks);
addSelectedCheck(checks, options, "microservice:code-queue-status", (codeQueueStatus as { ok?: boolean }).ok === true && (codeQueueStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === config.providerGateway.id, codeQueueStatus);
addSelectedCheck(checks, options, "microservice:code-queue-health", (codeQueueHealth as { ok?: boolean }).ok === true && codeQueueHealthBody?.ok === true && codeQueueHealthBody.queue?.defaultModel === "gpt-5.5" && codeQueueHealthBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codeQueueHealth);
addSelectedCheck(checks, options, "microservice:code-queue-tasks", (codeQueueTasks as { ok?: boolean }).ok === true && codeQueueTasksBody?.ok === true && Array.isArray(codeQueueTasksBody.tasks) && codeQueueTasksBody.queue?.defaultModel === "gpt-5.5" && codeQueueTasksBody.queue?.modelReasoningEfforts?.["gpt-5.5"] === "xhigh", codeQueueTasks);
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
method: "POST",
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
@@ -947,12 +1079,14 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const needMicroserviceCatalog = wants("frontend:microservice-catalog-visible");
const needTodoNote = wants("frontend:todo-note-integrated-visible");
const needFindJob = wants("frontend:findjob-integrated-visible");
const needCodexQueue = wantsAny([
"frontend:codex-queue-integrated-visible",
"frontend:codex-queue-initial-prompt-full-expand",
"frontend:codex-queue-trace-full-load",
"frontend:codex-queue-judge-wrap",
]);
const needCodeQueue = wantsAny([
"frontend:code-queue-integrated-visible",
"frontend:code-queue-summary-mobile-wrap",
"frontend:code-queue-initial-prompt-full-expand",
"frontend:code-queue-trace-full-load",
"frontend:code-queue-judge-wrap",
"frontend:code-queue-error-red-markers",
]);
const needClaudeqq = wants("frontend:claudeqq-integrated-visible");
const needRouteDeepLink = wants("frontend:url-route-deeplink");
const needPipeline = wantsAny([
@@ -1027,20 +1161,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
let microserviceCatalogText = "";
let todoNoteText = "";
let findjobText = "";
let codexQueueText = "";
let codexQueueOutputText = "";
let codexQueueTaskCount = 0;
let codexQueueOptions: string[] = [];
let codexQueueSwitchMetrics: any = { optionCount: 0, switched: false };
let codexQueueSubmitQueueControl: any = { tagName: "", createButtonVisible: false, oldInputMissing: false };
let codexQueueTracePlacement: any = { firstChildIsTrace: false, noPageTopStatus: false, filterInsideTracePanel: false, traceStatusVisible: false, markAllReadVisible: false };
let codexQueueGlobalStatus: any = { activeMicroserviceVisible: false };
let codexQueuePromptDefaultEmpty = false;
let codexQueueSubmitGuard: any = { batchRowVisible: false, disabledBeforeConfirm: false, enabledAfterConfirm: false, waitElementMissingBeforeSubmit: false };
let codexQueueScrollbarMetrics: any = { transcriptThin: false, toolHorizontalHidden: true };
let codeQueueText = "";
let codeQueueOutputText = "";
let codeQueueTaskCount = 0;
let codeQueueOptions: string[] = [];
let codeQueueSwitchMetrics: any = { optionCount: 0, switched: false };
let codeQueueSubmitQueueControl: any = { tagName: "", createButtonVisible: false, oldInputMissing: false };
let codeQueueTracePlacement: any = { firstChildIsTrace: false, noPageTopStatus: false, filterInsideTracePanel: false, traceStatusVisible: false, markAllReadVisible: false };
let codeQueueGlobalStatus: any = { activeMicroserviceVisible: false };
let codeQueueSidebarUpdateMetrics: any = { cardCount: 0, labels: [], hasRecentUpdateLabel: false };
let codeQueueHtmlGuard: any = { rootAttrMissing: false, sourceAttrMissing: false, sourceNoBasePrompt: false };
let codeQueueSummaryMobileMetrics: any = { checked: false, summaryCount: 0, ok: false };
let codeQueuePromptDefaultEmpty = false;
let codeQueueSubmitGuard: any = { batchRowVisible: false, disabledBeforeConfirm: false, enabledAfterConfirm: false, waitElementMissingBeforeSubmit: false };
let codeQueueScrollbarMetrics: any = { transcriptThin: false, toolHorizontalHidden: true };
let codexInitialPromptFullMetrics: any = { candidateFound: false };
let codexTraceFullMetrics: any = { candidateFound: false };
let codexJudgeWrapMetrics: any = { checked: false };
let codeQueueErrorHighlightMetrics: any = { checked: false, candidateFound: false };
let claudeqqText = "";
let routeDeepLinkText = "";
let routeInitialPath = "";
@@ -1237,9 +1375,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
}
}
if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodeQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
await page.getByRole("button", { name: /用户服务/ }).click();
if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodexQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
if (needMicroserviceCatalog || needTodoNote || needFindJob || needCodeQueue || needClaudeqq || needRouteDeepLink || needPipeline || needMetNonlinear) {
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
}
if (needMicroserviceCatalog) {
@@ -1248,7 +1386,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForSelector('[data-testid="microservice-row-met-nonlinear"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-claudeqq"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-todo-note"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-codex-queue"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-code-queue"]', { timeout: 10000 });
microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 });
}
if (needTodoNote) {
@@ -1304,13 +1442,13 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
}, undefined, { timeout: 30000 });
findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 });
}
if (needCodexQueue) {
await page.getByRole("button", { name: /Codex Queue/ }).click();
await page.waitForSelector('[data-testid="codex-queue-page"]', { timeout: 10000 });
if (needCodeQueue) {
await page.getByRole("button", { name: /Code Queue/ }).click();
await page.waitForSelector('[data-testid="code-queue-page"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText;
const lower = text.toLowerCase();
return lower.includes("codex queue")
return lower.includes("code queue")
&& text.includes("gpt-5.4-mini")
&& text.includes("gpt-5.4")
&& text.includes("gpt-5.5")
@@ -1321,24 +1459,27 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& text.includes("打断")
&& lower.includes("attempts");
}, undefined, { timeout: 30000 });
await page.waitForSelector('[data-testid="codex-queue-filter-select"]', { timeout: 10000 });
codexQueueTracePlacement = await page.evaluate(() => ({
firstChildIsTrace: Boolean(document.querySelector('[data-testid="codex-queue-page"] > .codex-session-stage:first-child .codex-output-panel')),
noPageTopStatus: document.querySelector('[data-testid="codex-queue-page"] > [data-testid="codex-top-status"]') === null,
filterInsideTracePanel: Boolean(document.querySelector('.codex-output-panel [data-testid="codex-queue-filter-select"]')),
await page.waitForSelector('[data-testid="code-queue-filter-select"]', { timeout: 10000 });
codeQueueTracePlacement = await page.evaluate(() => ({
firstChildIsTrace: Boolean(document.querySelector('[data-testid="code-queue-page"] > .codex-session-stage:first-child .codex-output-panel')),
noPageTopStatus: document.querySelector('[data-testid="code-queue-page"] > [data-testid="codex-top-status"]') === null,
filterInsideTracePanel: Boolean(document.querySelector('.codex-output-panel [data-testid="code-queue-filter-select"]')),
taskSearchVisible: Boolean(document.querySelector('[data-testid="codex-task-search-input"]')),
traceStatusVisible: /\s*\d+.*\s*\d+.*\s*\d+/s.test(document.querySelector('[data-testid="codex-trace-status-summary"]')?.textContent || ""),
markAllReadVisible: Boolean(document.querySelector('.codex-output-panel [data-testid="codex-mark-all-read-button"]')),
}));
codexQueueGlobalStatus = await page.evaluate(() => {
codeQueueGlobalStatus = await page.evaluate(() => {
const text = document.querySelector('[data-testid="active-microservice-status"]')?.textContent || "";
return { activeMicroserviceVisible: /Codex Queue.*在线|codex queue.*在线/i.test(text), text };
return { activeMicroserviceVisible: /Code Queue.*在线|code queue.*在线/i.test(text), text };
});
await page.waitForSelector('[data-testid="codex-queue-id-select"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="code-queue-id-select"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="codex-create-queue-button"]', { timeout: 10000 });
codexQueueSubmitQueueControl = await page.evaluate(() => {
const select = document.querySelector('[data-testid="codex-queue-id-select"]') as HTMLSelectElement | null;
codeQueueSubmitQueueControl = await page.evaluate(() => {
const select = document.querySelector('[data-testid="code-queue-id-select"]') as HTMLSelectElement | null;
const button = document.querySelector('[data-testid="codex-create-queue-button"]') as HTMLButtonElement | null;
const prompt = document.querySelector('[data-testid="codex-queue-task-form"] textarea') as HTMLTextAreaElement | null;
const prompt = document.querySelector('[data-testid="code-queue-task-form"] textarea') as HTMLTextAreaElement | null;
const provider = document.querySelector('[data-testid="codex-provider-select"]') as HTMLSelectElement | null;
const cwd = document.querySelector('[data-testid="codex-cwd-input"]') as HTMLInputElement | null;
const maxAttempts = document.querySelector('[data-testid="codex-max-attempts-input"]') as HTMLInputElement | null;
const moveSelect = document.querySelector('[data-testid="codex-task-queue-move-select"]') as HTMLSelectElement | null;
const moveButton = document.querySelector('[data-testid="codex-task-queue-move-button"]') as HTMLButtonElement | null;
@@ -1346,17 +1487,20 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
tagName: select?.tagName.toLowerCase() || "",
optionCount: select?.options.length ?? 0,
createButtonVisible: Boolean(button && button.offsetParent !== null),
oldInputMissing: document.querySelector('[data-testid="codex-queue-id-input"]') === null,
oldInputMissing: document.querySelector('[data-testid="code-queue-id-input"]') === null,
promptDefaultEmpty: (prompt?.value || "") === "",
providerValue: provider?.value || "",
providerOptions: Array.from(provider?.options || []).map((option) => ({ value: option.value, text: option.textContent || "" })),
cwdValue: cwd?.value || "",
maxAttemptsMax: maxAttempts?.max || "",
maxAttemptsValue: maxAttempts?.value || "",
moveQueueVisible: Boolean(moveSelect && moveSelect.offsetParent !== null && moveButton && moveButton.offsetParent !== null),
};
});
codexQueuePromptDefaultEmpty = Boolean(codexQueueSubmitQueueControl.promptDefaultEmpty);
await page.locator('[data-testid="codex-queue-task-form"] textarea').fill("e2e batch guard one\n---\ne2e batch guard two");
codeQueuePromptDefaultEmpty = Boolean(codeQueueSubmitQueueControl.promptDefaultEmpty);
await page.locator('[data-testid="code-queue-task-form"] textarea').fill("e2e batch guard one\n---\ne2e batch guard two");
await page.waitForSelector('[data-testid="codex-batch-confirm-row"]', { timeout: 5000 });
codexQueueSubmitGuard = await page.evaluate(() => {
codeQueueSubmitGuard = await page.evaluate(() => {
const row = document.querySelector('[data-testid="codex-batch-confirm-row"]') as HTMLElement | null;
const checkbox = document.querySelector('[data-testid="codex-batch-confirm-checkbox"]') as HTMLInputElement | null;
const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null;
@@ -1374,8 +1518,8 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null;
return button !== null && !button.disabled;
}, undefined, { timeout: 5000 });
codexQueueSubmitGuard = {
...codexQueueSubmitGuard,
codeQueueSubmitGuard = {
...codeQueueSubmitGuard,
...(await page.evaluate(() => {
const button = document.querySelector('[data-testid="codex-enqueue-button"]') as HTMLButtonElement | null;
return {
@@ -1385,8 +1529,8 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
})),
};
await page.locator('[data-testid="codex-clear-input-button"]').click();
codexQueueOptions = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || ""));
codexQueueSwitchMetrics = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => ({
codeQueueOptions = await page.locator('[data-testid="code-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || ""));
codeQueueSwitchMetrics = await page.locator('[data-testid="code-queue-filter-select"] option').evaluateAll((options) => ({
optionCount: options.length,
queueValues: options.map((option) => (option as HTMLOptionElement).value).filter((value) => value !== "__all__"),
switched: false,
@@ -1397,7 +1541,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const output = document.querySelector('[data-testid="codex-output"]')?.textContent || "";
return taskCount === 0 || output.includes("Submitted prompt");
}, undefined, { timeout: 15000 });
codexQueueScrollbarMetrics = await page.evaluate(() => {
codeQueueScrollbarMetrics = await page.evaluate(() => {
const transcript = document.querySelector('.codex-transcript') as HTMLElement | null;
const toolBlock = document.querySelector('.codex-transcript-item.ran .codex-transcript-command, .codex-transcript-item.ran .codex-transcript-body, .codex-transcript-item.explored .codex-transcript-command, .codex-transcript-item.explored .codex-transcript-body, .codex-transcript-item.edited .codex-transcript-command, .codex-transcript-item.edited .codex-transcript-body') as HTMLElement | null;
const transcriptStyle = transcript ? getComputedStyle(transcript) : null;
@@ -1411,7 +1555,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
toolOverflowX: toolStyle?.overflowX || "",
};
});
if (wants("frontend:codex-queue-judge-wrap")) {
if (wants("frontend:code-queue-judge-wrap")) {
codexJudgeWrapMetrics = await page.evaluate(() => {
const measure = (element: HTMLElement | null): any => {
if (element === null) return { found: false };
@@ -1489,15 +1633,77 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
};
});
}
codexQueueTaskCount = await page.locator('[data-testid^="codex-task-codex_"]').count();
if (wants("frontend:codex-queue-initial-prompt-full-expand")) {
codeQueueSidebarUpdateMetrics = await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll('[data-testid="codex-session-sidebar"] .codex-task-card')) as HTMLElement[];
const labels = cards
.map((card) => card.querySelector('[data-testid^="codex-task-recent-update-"]')?.textContent || "")
.filter(Boolean);
return {
cardCount: cards.length,
labels,
hasRecentUpdateLabel: cards.length === 0 || labels.some((text) => /^:\s*(|--|\d+|\d+\d+|\d+|\d+)/u.test(text)),
};
});
if (wantsAny(["frontend:code-queue-integrated-visible", "frontend:code-queue-summary-mobile-wrap"])) {
await page.setViewportSize({ width: 390, height: 860 });
await page.waitForTimeout(120);
codeQueueSummaryMobileMetrics = await page.evaluate(() => {
const viewportWidth = document.documentElement.clientWidth;
const summaries = Array.from(document.querySelectorAll(".codex-execution-summary")) as HTMLElement[];
const items = summaries.slice(0, 8).map((summary, index) => {
const header = summary.querySelector(".codex-progressive-card-head") as HTMLElement | null;
const meta = header?.querySelector("code") as HTMLElement | null;
const headerRect = header?.getBoundingClientRect();
const metaRect = meta?.getBoundingClientRect();
const rootOverflowPx = Math.max(0, summary.scrollWidth - summary.clientWidth);
const headerOverflowPx = header ? Math.max(0, header.scrollWidth - header.clientWidth) : 0;
const metaOverflowPx = meta ? Math.max(0, meta.scrollWidth - meta.clientWidth) : 0;
return {
index,
text: (header?.textContent || "").replace(/\s+/g, " ").trim().slice(0, 180),
viewportWidth,
rootClientWidth: Math.round(summary.clientWidth),
rootScrollWidth: Math.round(summary.scrollWidth),
headerClientWidth: header ? Math.round(header.clientWidth) : 0,
headerScrollWidth: header ? Math.round(header.scrollWidth) : 0,
metaClientWidth: meta ? Math.round(meta.clientWidth) : 0,
metaScrollWidth: meta ? Math.round(meta.scrollWidth) : 0,
rootOverflowPx: Math.round(rootOverflowPx),
headerOverflowPx: Math.round(headerOverflowPx),
metaOverflowPx: Math.round(metaOverflowPx),
headerRight: headerRect ? Math.round(headerRect.right) : 0,
metaRight: metaRect ? Math.round(metaRect.right) : 0,
metaWhiteSpace: meta ? getComputedStyle(meta).whiteSpace : "",
metaOverflowWrap: meta ? getComputedStyle(meta).overflowWrap : "",
};
});
return {
checked: true,
viewportWidth,
summaryCount: summaries.length,
items,
ok: summaries.length === 0 || items.every((item) =>
item.rootOverflowPx <= 1
&& item.headerOverflowPx <= 1
&& item.metaOverflowPx <= 1
&& item.headerRight <= viewportWidth + 2
&& item.metaRight <= viewportWidth + 2
&& item.metaWhiteSpace !== "nowrap"
&& (item.metaOverflowWrap === "anywhere" || item.metaOverflowWrap === "break-word")),
};
});
await page.setViewportSize({ width: 1440, height: 920 });
await page.waitForTimeout(120);
}
codeQueueTaskCount = await page.locator('[data-testid^="codex-task-codex_"]').count();
if (wants("frontend:code-queue-initial-prompt-full-expand")) {
codexInitialPromptFullMetrics = await page.evaluate(async () => {
const tasksResponse = await fetch("/api/microservices/codex-queue/proxy/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" });
const tasksResponse = await fetch("/api/microservices/code-queue/proxy/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" });
const tasksPayload = await tasksResponse.json().catch(() => null);
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
const candidate = tasks.find((task: any) => Array.isArray(task?.referenceTaskIds) && task.referenceTaskIds.length > 0);
if (!candidate?.id) return { candidateFound: false };
const metaResponse = await fetch(`/api/microservices/codex-queue/proxy/api/tasks/${encodeURIComponent(String(candidate.id))}?meta=1`, { credentials: "same-origin" });
const metaResponse = await fetch(`/api/microservices/code-queue/proxy/api/tasks/${encodeURIComponent(String(candidate.id))}?meta=1`, { credentials: "same-origin" });
const metaPayload = await metaResponse.json().catch(() => null);
const fullPrompt = String(metaPayload?.task?.prompt || "");
const displayPrompt = String(metaPayload?.task?.displayPrompt || "");
@@ -1506,7 +1712,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
taskId: String(candidate.id),
promptChars: fullPrompt.length,
displayPromptChars: displayPrompt.length,
hasResolvedReference: fullPrompt.includes("# Codex Queue 已解析引用上下文"),
hasResolvedReference: fullPrompt.includes("# Code Queue 已解析引用上下文"),
hasCurrentTaskMarker: fullPrompt.includes("# 本次任务"),
hasReferenceTaskId: /codex_\\d+_[A-Za-z0-9_-]+/u.test(fullPrompt),
};
@@ -1521,7 +1727,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForFunction(() => (document.querySelector('[data-testid="codex-initial-prompt-full"]') as HTMLDetailsElement | null)?.open === true, undefined, { timeout: 5000 });
const initialFullText = await page.getByTestId("codex-initial-prompt-full-text").innerText({ timeout: 5000 });
codexInitialPromptFullMetrics.initialExpanded = await page.getByTestId("codex-initial-prompt-full").evaluate((element) => (element as HTMLDetailsElement).open);
codexInitialPromptFullMetrics.initialFullHasReference = initialFullText.includes("# Codex Queue 已解析引用上下文") || initialFullText.includes("引用 Codex Queue");
codexInitialPromptFullMetrics.initialFullHasReference = initialFullText.includes("# Code Queue 已解析引用上下文") || initialFullText.includes("引用 Code Queue");
codexInitialPromptFullMetrics.initialFullHasCurrentTask = initialFullText.includes("# 本次任务") || initialFullText.includes("本次任务:");
codexInitialPromptFullMetrics.initialFullChars = initialFullText.length;
codexInitialPromptFullMetrics.legacyPromptPanelMissing = await page.evaluate(() =>
@@ -1531,16 +1737,16 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
);
}
}
if (wants("frontend:codex-queue-trace-full-load")) {
if (wants("frontend:code-queue-trace-full-load")) {
codexTraceFullMetrics = await page.evaluate(async () => {
const tasksResponse = await fetch("/api/codex-queue-direct/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" });
const tasksResponse = await fetch("/api/code-queue-direct/api/tasks?limit=300&lite=1&devReady=0", { credentials: "same-origin" });
const tasksPayload = await tasksResponse.json().catch(() => null);
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
const terminal = new Set(["succeeded", "failed", "canceled"]);
for (const task of tasks) {
const taskId = String(task?.id || "");
if (!taskId || !terminal.has(String(task?.status || "")) || Number(task?.outputCount || 0) < 20) continue;
const transcriptResponse = await fetch(`/api/codex-queue-direct/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=0&limit=120&fullText=1`, { credentials: "same-origin" });
const transcriptResponse = await fetch(`/api/code-queue-direct/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=0&limit=120&fullText=1`, { credentials: "same-origin" });
const transcriptPayload = await transcriptResponse.json().catch(() => null);
const transcript = Array.isArray(transcriptPayload?.transcript) ? transcriptPayload.transcript : [];
const toolCount = transcript.filter((line: any) => ["ran", "explored", "edited"].includes(String(line?.kind || ""))).length;
@@ -1569,7 +1775,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await traceTaskCard.scrollIntoViewIfNeeded({ timeout: 10000 });
await traceTaskCard.click();
await page.waitForFunction(() => {
const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null;
const pageElement = document.querySelector('[data-testid="code-queue-page"]') as HTMLElement | null;
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
const toolCount = output?.querySelectorAll('.codex-transcript-item.ran, .codex-transcript-item.explored, .codex-transcript-item.edited').length ?? 0;
return pageElement?.getAttribute("data-load-state") === "complete" && toolCount >= 8;
@@ -1577,7 +1783,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
codexTraceFullMetrics = {
...codexTraceFullMetrics,
...(await page.evaluate(() => {
const pageElement = document.querySelector('[data-testid="codex-queue-page"]') as HTMLElement | null;
const pageElement = document.querySelector('[data-testid="code-queue-page"]') as HTMLElement | null;
const output = document.querySelector('[data-testid="codex-output"]') as HTMLElement | null;
const text = output?.textContent || "";
return {
@@ -1594,13 +1800,144 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
};
}
}
codexQueueOutputText = await page.locator('[data-testid="codex-output"]').innerText({ timeout: 5000 });
codexQueueText = await page.locator('[data-testid="codex-queue-page"]').innerText({ timeout: 5000 });
codexQueueOptions = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || ""));
codexQueueSubmitQueueControl = await page.evaluate(() => {
const select = document.querySelector('[data-testid="codex-queue-id-select"]') as HTMLSelectElement | null;
if (wants("frontend:code-queue-error-red-markers")) {
const authCookies = await page.context().cookies(urls.frontendUrl);
const cookieHeader = authCookies.map(({ name, value }) => `${name}=${value}`).join("; ");
const authHeaders: Record<string, string> = { accept: "application/json" };
if (cookieHeader) authHeaders.cookie = cookieHeader;
const fetchJson = async (path: string): Promise<any> => {
const response = await fetch(new URL(path, urls.frontendUrl), { headers: authHeaders });
return response.json().catch(() => null);
};
const tasksPayload = await fetchJson("/api/code-queue-direct/api/tasks?limit=300&lite=1&devReady=0");
const tasks = Array.isArray(tasksPayload?.tasks) ? tasksPayload.tasks : [];
const prioritized = tasks
.filter((task: any) => ["failed", "canceled", "running", "judging", "succeeded"].includes(String(task?.status || "")))
.concat(tasks);
for (const task of prioritized) {
const taskId = String(task?.id || "");
if (!taskId) continue;
const summaryPayload = await fetchJson(`/api/code-queue-direct/api/tasks/${encodeURIComponent(taskId)}/trace-summary`);
const summary = summaryPayload?.summary || null;
const attempts = Array.isArray(summary?.attempts) ? summary.attempts : [];
const errorAttempt = attempts.find((attempt: any) => Number(attempt?.errorCount ?? 0) > 0) || null;
const errorCount = Number(errorAttempt?.errorCount ?? summary?.errorCount ?? 0);
const attemptIndex = Number(errorAttempt?.index ?? 0);
if (Number.isFinite(errorCount) && errorCount > 0 && Number.isFinite(attemptIndex) && attemptIndex > 0) {
codeQueueErrorHighlightMetrics = {
checked: true,
candidateFound: true,
taskId,
errorCount,
attemptIndex,
status: String(task?.status || summary?.status || ""),
};
break;
}
}
if (!codeQueueErrorHighlightMetrics) {
codeQueueErrorHighlightMetrics = { checked: true, candidateFound: false, taskCount: tasks.length };
}
if (codeQueueErrorHighlightMetrics.candidateFound) {
const errorTaskSearch = page.getByTestId("codex-task-search-input");
if (await errorTaskSearch.count()) {
await errorTaskSearch.fill(codeQueueErrorHighlightMetrics.taskId);
await page.waitForTimeout(1000);
}
for (let index = 0; index < 8; index += 1) {
if (await page.getByTestId(`codex-task-${codeQueueErrorHighlightMetrics.taskId}`).count()) break;
const moreButton = page.getByTestId("codex-load-more-tasks-button");
if (!(await moreButton.count())) break;
await moreButton.scrollIntoViewIfNeeded().catch(() => undefined);
await moreButton.click();
await page.waitForTimeout(500);
}
const errorTaskCard = page.getByTestId(`codex-task-${codeQueueErrorHighlightMetrics.taskId}`);
await errorTaskCard.scrollIntoViewIfNeeded({ timeout: 10000 });
await errorTaskCard.click();
await page.waitForSelector('[data-testid="codex-execution-summary"]', { timeout: 15000 });
const errorSummaryTestId = Number(codeQueueErrorHighlightMetrics.attemptIndex || 0) > 1
? `codex-execution-summary-attempt-${Number(codeQueueErrorHighlightMetrics.attemptIndex)}`
: "codex-execution-summary";
const errorPillTestId = `${errorSummaryTestId}-error-count`;
const errorSummaryLocator = page.getByTestId(errorSummaryTestId);
const errorPillLocator = page.getByTestId(errorPillTestId);
await errorPillLocator.waitFor({ state: "visible", timeout: 30000 });
const collapsedMetrics = await page.evaluate(({ summaryTestId, pillTestId }) => {
const summary = document.querySelector(`[data-testid="${summaryTestId}"]`) as HTMLDetailsElement | null;
const pill = document.querySelector(`[data-testid="${pillTestId}"]`) as HTMLElement | null;
const pillStyle = pill ? getComputedStyle(pill) : null;
const dangerColor = /207,\s*106,\s*84/u;
return {
summaryOpen: Boolean(summary?.open),
pillVisible: Boolean(pill && pill.offsetParent !== null),
pillText: String(pill?.textContent || "").trim(),
pillClass: String(pill?.className || ""),
pillColor: pillStyle?.color || "",
pillBorderColor: pillStyle?.borderColor || "",
pillBackgroundColor: pillStyle?.backgroundColor || "",
hasDangerColor: dangerColor.test(pillStyle?.color || "") && dangerColor.test(pillStyle?.borderColor || ""),
};
}, { summaryTestId: errorSummaryTestId, pillTestId: errorPillTestId });
await errorSummaryLocator.locator("summary").click();
await page.waitForFunction((testId) => (document.querySelector(`[data-testid="${testId}"]`) as HTMLDetailsElement | null)?.open === true, errorSummaryTestId, { timeout: 5000 });
await page.waitForFunction((testId) => document.querySelectorAll(`[data-testid="${testId}"] .codex-trace-step.error`).length > 0, errorSummaryTestId, { timeout: 15000 });
const expandedMetrics = await page.evaluate(({ summaryTestId }) => {
const summary = document.querySelector(`[data-testid="${summaryTestId}"]`) as HTMLDetailsElement | null;
const errorSteps = Array.from(summary?.querySelectorAll(".codex-trace-step.error") || []) as HTMLElement[];
const first = errorSteps[0] || null;
const channel = first?.querySelector(".codex-output-channel") as HTMLElement | null;
const code = first?.querySelector("code") as HTMLElement | null;
const firstStyle = first ? getComputedStyle(first) : null;
const channelStyle = channel ? getComputedStyle(channel) : null;
const codeStyle = code ? getComputedStyle(code) : null;
const dangerColor = /207,\s*106,\s*84/u;
return {
errorStepCount: errorSteps.length,
firstStepClass: String(first?.className || ""),
firstStepBorderColor: firstStyle?.borderColor || "",
firstStepBackgroundColor: firstStyle?.backgroundColor || "",
firstChannelColor: channelStyle?.color || "",
firstCodeColor: codeStyle?.color || "",
hasDangerColor: dangerColor.test(firstStyle?.borderColor || "") && dangerColor.test(channelStyle?.color || "") && dangerColor.test(codeStyle?.color || ""),
};
}, { summaryTestId: errorSummaryTestId });
codeQueueErrorHighlightMetrics = {
...codeQueueErrorHighlightMetrics,
collapsed: collapsedMetrics,
expanded: expandedMetrics,
ok: collapsedMetrics.pillVisible === true
&& collapsedMetrics.pillText.startsWith("Error ")
&& collapsedMetrics.hasDangerColor === true
&& collapsedMetrics.summaryOpen === false
&& expandedMetrics.errorStepCount > 0
&& expandedMetrics.firstStepClass.includes("error")
&& expandedMetrics.hasDangerColor === true,
};
}
}
codeQueueOutputText = await page.locator('[data-testid="codex-output"]').innerText({ timeout: 5000 });
codeQueueText = await page.locator('[data-testid="code-queue-page"]').innerText({ timeout: 5000 });
codeQueueHtmlGuard = await page.evaluate(async () => {
const root = document.getElementById("root");
const overviewAttr = root?.getAttribute("data-codex-overview") || "";
const response = await fetch(window.location.pathname, { credentials: "same-origin" });
const html = await response.text();
return {
rootAttrLength: overviewAttr.length,
rootAttrMissing: overviewAttr.length === 0,
sourceBytes: html.length,
sourceAttrMissing: !html.includes("data-codex-overview"),
sourceNoBasePrompt: !html.includes("basePrompt"),
};
});
codeQueueOptions = await page.locator('[data-testid="code-queue-filter-select"] option').evaluateAll((options) => options.map((option) => (option as HTMLOptionElement).textContent || ""));
codeQueueSubmitQueueControl = await page.evaluate(() => {
const select = document.querySelector('[data-testid="code-queue-id-select"]') as HTMLSelectElement | null;
const button = document.querySelector('[data-testid="codex-create-queue-button"]') as HTMLButtonElement | null;
const prompt = document.querySelector('[data-testid="codex-queue-task-form"] textarea') as HTMLTextAreaElement | null;
const prompt = document.querySelector('[data-testid="code-queue-task-form"] textarea') as HTMLTextAreaElement | null;
const provider = document.querySelector('[data-testid="codex-provider-select"]') as HTMLSelectElement | null;
const cwd = document.querySelector('[data-testid="codex-cwd-input"]') as HTMLInputElement | null;
const maxAttempts = document.querySelector('[data-testid="codex-max-attempts-input"]') as HTMLInputElement | null;
const moveSelect = document.querySelector('[data-testid="codex-task-queue-move-select"]') as HTMLSelectElement | null;
const moveButton = document.querySelector('[data-testid="codex-task-queue-move-button"]') as HTMLButtonElement | null;
@@ -1608,22 +1945,25 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
tagName: select?.tagName.toLowerCase() || "",
optionCount: select?.options.length ?? 0,
createButtonVisible: Boolean(button && button.offsetParent !== null),
oldInputMissing: document.querySelector('[data-testid="codex-queue-id-input"]') === null,
oldInputMissing: document.querySelector('[data-testid="code-queue-id-input"]') === null,
promptDefaultEmpty: (prompt?.value || "") === "",
providerValue: provider?.value || "",
providerOptions: Array.from(provider?.options || []).map((option) => ({ value: option.value, text: option.textContent || "" })),
cwdValue: cwd?.value || "",
maxAttemptsMax: maxAttempts?.max || "",
maxAttemptsValue: maxAttempts?.value || "",
moveQueueVisible: Boolean(moveSelect && moveSelect.offsetParent !== null && moveButton && moveButton.offsetParent !== null),
};
});
codexQueuePromptDefaultEmpty = Boolean(codexQueueSubmitQueueControl.promptDefaultEmpty);
codexQueueSwitchMetrics = await page.locator('[data-testid="codex-queue-filter-select"] option').evaluateAll((options) => ({
codeQueuePromptDefaultEmpty = Boolean(codeQueueSubmitQueueControl.promptDefaultEmpty);
codeQueueSwitchMetrics = await page.locator('[data-testid="code-queue-filter-select"] option').evaluateAll((options) => ({
optionCount: options.length,
queueValues: options.map((option) => (option as HTMLOptionElement).value).filter((value) => value !== "__all__"),
switched: false,
}));
if (codexQueueSwitchMetrics.queueValues.length > 0) {
const targetQueueId = String(codexQueueSwitchMetrics.queueValues.find((value: string) => value !== "default") || codexQueueSwitchMetrics.queueValues[0]);
await page.getByTestId("codex-queue-filter-select").selectOption(targetQueueId);
if (codeQueueSwitchMetrics.queueValues.length > 0) {
const targetQueueId = String(codeQueueSwitchMetrics.queueValues.find((value: string) => value !== "default") || codeQueueSwitchMetrics.queueValues[0]);
await page.getByTestId("code-queue-filter-select").selectOption(targetQueueId);
await page.waitForFunction((queueId) => {
const text = document.body.innerText;
return text.includes(`view=${queueId}`) || text.includes(`${queueId} ·`);
@@ -1632,8 +1972,8 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const cards = Array.from(document.querySelectorAll('[data-testid^="codex-task-codex_"]')).map((node) => (node as HTMLElement).innerText);
return cards.length === 0 || cards.every((text) => text.includes(`queue=${queueId}`));
}, targetQueueId, { timeout: 15000 });
codexQueueSwitchMetrics = { ...codexQueueSwitchMetrics, targetQueueId, switched: true };
await page.getByTestId("codex-queue-filter-select").selectOption("__all__");
codeQueueSwitchMetrics = { ...codeQueueSwitchMetrics, targetQueueId, switched: true };
await page.getByTestId("code-queue-filter-select").selectOption("__all__");
}
}
if (needClaudeqq) {
@@ -1674,19 +2014,19 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForSelector('[data-testid="overview-page"]', { timeout: 10000 });
routeOverviewPath = new URL(page.url()).pathname;
routeOverviewText = await page.locator('[data-testid="overview-page"]').innerText({ timeout: 5000 });
await page.goto(`${urls.frontendUrl}/app/codex-queue/`, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.goto(`${urls.frontendUrl}/app/code-queue/`, { waitUntil: "domcontentloaded", timeout: 15000 });
await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="codex-queue-page"]', { timeout: 15000 });
await page.waitForSelector('[data-testid="code-queue-page"]', { timeout: 15000 });
routeCodexPath = new URL(page.url()).pathname;
routeCodexShellMetrics = await page.evaluate(() => ({
appShell: Boolean(document.querySelector('[data-testid="app-shell"]')),
standalone: Boolean(document.querySelector('[data-testid="codex-queue-standalone"]')),
standalone: Boolean(document.querySelector('[data-testid="code-queue-standalone"]')),
railText: document.querySelector(".rail")?.textContent || "",
topbar: Boolean(document.querySelector(".topbar")),
tabsText: document.querySelector(".tabs")?.textContent || "",
codexPage: Boolean(document.querySelector('[data-testid="codex-queue-page"]')),
codexPage: Boolean(document.querySelector('[data-testid="code-queue-page"]')),
}));
await page.locator('.rail button[title="用户服务"]').click();
await page.locator('.rail [role="button"][title="用户服务"]').click();
await page.getByRole("button", { name: /^服务目录$/ }).click();
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
}
@@ -2050,7 +2390,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase();
const todoNoteTextLower = todoNoteText.toLowerCase();
const findjobTextLower = findjobText.toLowerCase();
const codexQueueTextLower = codexQueueText.toLowerCase();
const codeQueueTextLower = codeQueueText.toLowerCase();
const claudeqqTextLower = claudeqqText.toLowerCase();
const pipelineTextLower = pipelineText.toLowerCase();
const activePipeline = Array.isArray(pipelineSnapshotForFrontend?.pipelines)
@@ -2084,13 +2424,15 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
addSelectedCheck(checks, options, "frontend:gateway-duration-subsecond-visible", gatewayHasSubsecondDuration && !gatewayHasRoundedZeroDuration, { gatewayHasSubsecondDuration, gatewayHasRoundedZeroDuration, gatewayTextPreview: gatewayText.slice(0, 900) });
addSelectedCheck(checks, options, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts });
addSelectedCheck(checks, options, "frontend:overview-pgdata-visible", bodyText.includes("PGDATA") && bodyText.includes(config.database.volume), { bodyPreview: bodyText.slice(0, 800) });
addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("claudeqq") && microserviceCatalogTextLower.includes("codex queue") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/lyon1998/agent_skills") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 2000) });
addSelectedCheck(checks, options, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogTextLower.includes("todo note") && microserviceCatalogTextLower.includes("met nonlinear") && microserviceCatalogTextLower.includes("claudeqq") && microserviceCatalogTextLower.includes("code queue") && microserviceCatalogText.includes("D601") && microserviceCatalogText.includes(config.providerGateway.id) && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline") && microserviceCatalogText.includes("https://github.com/pikasTech/met_nonlinear") && microserviceCatalogText.includes("https://gitee.com/lyon1998/agent_skills") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/todo_note") && microserviceCatalogText.includes("https://github.com/pikasTech/unidesk"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 2000) });
addSelectedCheck(checks, options, "frontend:todo-note-integrated-visible", todoNoteTextLower.includes("todo note 工作台") && todoNoteText.includes("CONSTAR") && todoNoteText.includes("大论文") && todoNoteText.includes("UI E2E smoke task") && todoNoteText.includes("撤销") && todoNoteText.includes("重做") && todoNoteText.includes("全部展开") && todoNoteText.includes("仅 UniDesk frontend 代理访问"), { todoNoteTextPreview: todoNoteText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
addSelectedCheck(checks, options, "frontend:codex-queue-integrated-visible", codexQueueTextLower.includes("codex queue") && codexQueueText.includes("gpt-5.4-mini") && codexQueueText.includes("gpt-5.4") && codexQueueText.includes("gpt-5.5") && codexQueueText.includes("提交任务") && codexQueueText.includes("入队份数") && codexQueueText.includes("追加 prompt") && codexQueueText.includes("打断") && codexQueueTextLower.includes("查看 queue") && codexQueueText.includes("创建 queue") && codexQueueOptions.some((text) => text.includes("All queues")) && codexQueueTracePlacement.firstChildIsTrace === true && codexQueueTracePlacement.noPageTopStatus === true && codexQueueTracePlacement.filterInsideTracePanel === true && codexQueueTracePlacement.traceStatusVisible === true && codexQueueTracePlacement.markAllReadVisible === true && codexQueueGlobalStatus.activeMicroserviceVisible === true && codexQueueSubmitQueueControl.tagName === "select" && codexQueueSubmitQueueControl.createButtonVisible === true && codexQueueSubmitQueueControl.oldInputMissing === true && codexQueueSubmitQueueControl.maxAttemptsMax === "99" && codexQueueSubmitQueueControl.maxAttemptsValue === "99" && codexQueueSubmitQueueControl.moveQueueVisible === true && codexQueuePromptDefaultEmpty === true && codexQueueSubmitGuard.batchRowVisible === true && codexQueueSubmitGuard.checkboxVisible === true && codexQueueSubmitGuard.disabledBeforeConfirm === true && codexQueueSubmitGuard.enabledAfterConfirm === true && codexQueueSubmitGuard.waitElementMissingBeforeSubmit === true && codexQueueScrollbarMetrics.transcriptThin === true && codexQueueScrollbarMetrics.toolHorizontalHidden === true && (codexQueueSwitchMetrics.optionCount <= 1 || codexQueueSwitchMetrics.switched === true) && codexQueueTextLower.includes("attempts") && codexQueueText.includes("仅 UniDesk frontend 代理访问") && (codexQueueTaskCount === 0 || codexQueueOutputText.includes("Submitted prompt")), { codexQueueTaskCount, codexQueueOptions, codexQueueSwitchMetrics, codexQueueSubmitQueueControl, codexQueueSubmitGuard, codexQueueScrollbarMetrics, codexQueuePromptDefaultEmpty, codexQueueTracePlacement, codexQueueGlobalStatus, codexQueueOutputPreview: codexQueueOutputText.slice(0, 900), codexQueueTextPreview: codexQueueText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:codex-queue-initial-prompt-full-expand",
codexInitialPromptFullMetrics.candidateFound === false
|| (
addSelectedCheck(checks, options, "frontend:code-queue-integrated-visible", codeQueueTextLower.includes("code queue") && codeQueueText.includes("gpt-5.4-mini") && codeQueueText.includes("gpt-5.4") && codeQueueText.includes("gpt-5.5") && codeQueueText.includes("提交任务") && codeQueueText.includes("执行 Provider") && codeQueueText.includes("入队份数") && codeQueueText.includes("追加 prompt") && codeQueueText.includes("打断") && codeQueueTextLower.includes("查看 queue") && codeQueueText.includes("创建 queue") && codeQueueOptions.some((text) => text.includes("All queues")) && codeQueueTracePlacement.firstChildIsTrace === true && codeQueueTracePlacement.noPageTopStatus === true && codeQueueTracePlacement.filterInsideTracePanel === true && codeQueueTracePlacement.taskSearchVisible === true && codeQueueTracePlacement.traceStatusVisible === true && codeQueueTracePlacement.markAllReadVisible === true && codeQueueGlobalStatus.activeMicroserviceVisible === true && codeQueueSidebarUpdateMetrics.hasRecentUpdateLabel === true && codeQueueHtmlGuard.rootAttrMissing === true && codeQueueHtmlGuard.sourceAttrMissing === true && codeQueueHtmlGuard.sourceNoBasePrompt === true && codeQueueSubmitQueueControl.tagName === "select" && codeQueueSubmitQueueControl.createButtonVisible === true && codeQueueSubmitQueueControl.oldInputMissing === true && codeQueueSubmitQueueControl.providerValue === "main-server" && codeQueueSubmitQueueControl.cwdValue === "/root/unidesk" && Array.isArray(codeQueueSubmitQueueControl.providerOptions) && codeQueueSubmitQueueControl.providerOptions.some((item: any) => item.value === "D601" && String(item.text || "").includes("/home/ubuntu")) && codeQueueSubmitQueueControl.maxAttemptsMax === "99" && codeQueueSubmitQueueControl.maxAttemptsValue === "99" && codeQueueSubmitQueueControl.moveQueueVisible === true && codeQueuePromptDefaultEmpty === true && codeQueueSubmitGuard.batchRowVisible === true && codeQueueSubmitGuard.checkboxVisible === true && codeQueueSubmitGuard.disabledBeforeConfirm === true && codeQueueSubmitGuard.enabledAfterConfirm === true && codeQueueSubmitGuard.waitElementMissingBeforeSubmit === true && codeQueueScrollbarMetrics.transcriptThin === true && codeQueueScrollbarMetrics.toolHorizontalHidden === true && (codeQueueSwitchMetrics.optionCount <= 1 || codeQueueSwitchMetrics.switched === true) && codeQueueTextLower.includes("attempts") && codeQueueText.includes("仅 UniDesk frontend 代理访问") && (codeQueueTaskCount === 0 || codeQueueOutputText.includes("Submitted prompt")), { codeQueueTaskCount, codeQueueOptions, codeQueueSwitchMetrics, codeQueueSubmitQueueControl, codeQueueSubmitGuard, codeQueueScrollbarMetrics, codeQueuePromptDefaultEmpty, codeQueueTracePlacement, codeQueueGlobalStatus, codeQueueSidebarUpdateMetrics, codeQueueHtmlGuard, codeQueueOutputPreview: codeQueueOutputText.slice(0, 900), codeQueueTextPreview: codeQueueText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:code-queue-summary-mobile-wrap", codeQueueSummaryMobileMetrics.checked === true && (codeQueueSummaryMobileMetrics.summaryCount === 0 || codeQueueSummaryMobileMetrics.ok === true), { codeQueueSummaryMobileMetrics });
addSelectedCheck(checks, options, "frontend:code-queue-error-red-markers", codeQueueErrorHighlightMetrics.checked === true && codeQueueErrorHighlightMetrics.candidateFound === true && codeQueueErrorHighlightMetrics.ok === true, { codeQueueErrorHighlightMetrics });
addSelectedCheck(checks, options, "frontend:code-queue-initial-prompt-full-expand",
codexInitialPromptFullMetrics.candidateFound === false
|| (
codexInitialPromptFullMetrics.promptChars > codexInitialPromptFullMetrics.displayPromptChars
&& codexInitialPromptFullMetrics.initialDefaultOpen === false
&& codexInitialPromptFullMetrics.initialExpanded === true
@@ -2099,7 +2441,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& codexInitialPromptFullMetrics.legacyPromptPanelMissing === true
),
{ codexInitialPromptFullMetrics });
addSelectedCheck(checks, options, "frontend:codex-queue-trace-full-load",
addSelectedCheck(checks, options, "frontend:code-queue-trace-full-load",
codexTraceFullMetrics.candidateFound === false
|| (
codexTraceFullMetrics.apiTotal >= 20
@@ -2113,11 +2455,11 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
&& codexTraceFullMetrics.loadPartial !== "true"
),
{ codexTraceFullMetrics });
addSelectedCheck(checks, options, "frontend:codex-queue-judge-wrap",
addSelectedCheck(checks, options, "frontend:code-queue-judge-wrap",
codexJudgeWrapMetrics.checked === true && codexJudgeWrapMetrics.ok === true,
{ codexJudgeWrapMetrics });
addSelectedCheck(checks, options, "frontend:claudeqq-integrated-visible", claudeqqTextLower.includes("claudeqq 工作台") && claudeqqText.includes("D601") && claudeqqText.includes("QQ 事件订阅") && claudeqqText.includes("消息推送") && claudeqqText.includes("事件缓存") && claudeqqText.includes("主用户私聊账号") && claudeqqText.includes("645275593") && claudeqqTextLower.includes("napcat 容器登录") && (claudeqqText.includes("二维码") || claudeqqText.includes("QR SOURCE") || claudeqqText.includes("QR Source") || claudeqqText.includes("已登录")) && claudeqqText.includes("仅 UniDesk frontend 代理访问") && !claudeqqText.includes("{\n"), { claudeqqTextPreview: claudeqqText.slice(0, 1400) });
addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeCodexPath === "/app/codex-queue/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标") && routeCodexShellMetrics.appShell === true && routeCodexShellMetrics.standalone === false && routeCodexShellMetrics.topbar === true && routeCodexShellMetrics.codexPage === true && String(routeCodexShellMetrics.railText || "").includes("用户服务") && String(routeCodexShellMetrics.tabsText || "").includes("Codex Queue"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeCodexPath, routeCodexShellMetrics, routeDeepLinkPreview: routeDeepLinkText.slice(0, 1200), routeOverviewPreview: routeOverviewText.slice(0, 800) });
addSelectedCheck(checks, options, "frontend:url-route-deeplink", routeInitialPath === "/app/pipeline/" && routeDockerPath === "/nodes/docker/" && routeBackPath === "/app/pipeline/" && routeOverviewPath === "/ops/status/" && routeCodexPath === "/app/code-queue/" && routeDeepLinkText.toLowerCase().includes("pipeline v2 工作台".toLowerCase()) && routeOverviewText.includes("核心指标") && routeCodexShellMetrics.appShell === true && routeCodexShellMetrics.standalone === false && routeCodexShellMetrics.topbar === true && routeCodexShellMetrics.codexPage === true && String(routeCodexShellMetrics.railText || "").includes("用户服务") && String(routeCodexShellMetrics.tabsText || "").includes("Code Queue"), { routeInitialPath, routeDockerPath, routeBackIntermediatePath, routeBackPath, routeOverviewPath, routeCodexPath, routeCodexShellMetrics, routeDeepLinkPreview: routeDeepLinkText.slice(0, 1200), routeOverviewPreview: routeOverviewText.slice(0, 800) });
addSelectedCheck(checks, options, "frontend:pipeline-integrated-visible",
pipelineTextLower.includes("pipeline v2 工作台".toLowerCase())
&& pipelineText.includes("D601")
@@ -2194,7 +2536,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
{ pipelineObservationGanttMetrics });
addSelectedCheck(checks, options, "frontend:pipeline-step-timeline-visible",
pipelineStepTimelineText.includes("OpenCode Trace")
&& pipelineStepTimelineText.includes("Codex Queue")
&& pipelineStepTimelineText.includes("Code Queue")
&& pipelineStepTimelineText.toLowerCase().includes("tools")
&& pipelineStepTimelineText.includes("Trace")
&& !firstPipelineStepSummaryText.includes("{\n")
+1 -1
View File
@@ -97,7 +97,7 @@ export function startJob(name: string, command: string[], note: string, options:
repoRoot,
"--entrypoint",
"bun",
options.dockerImage ?? "unidesk-codex-queue:latest",
options.dockerImage ?? "unidesk-code-queue:latest",
rootPath("scripts", "cli.ts"),
"internal",
"run-job",
+15 -1
View File
@@ -50,6 +50,20 @@ function numberOption(args: string[], name: string, defaultValue: number): numbe
return value;
}
function stringOption(args: string[], name: string): string | undefined {
const index = args.indexOf(name);
if (index === -1) return undefined;
const raw = args[index + 1];
if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`);
return raw;
}
function methodOption(args: string[]): string {
const method = (stringOption(args, "--method") ?? "GET").toUpperCase();
if (!["GET", "HEAD", "POST", "DELETE", "PUT", "PATCH"].includes(method)) throw new Error(`unsupported --method ${method}`);
return method;
}
export function summarizeMicroserviceProxyResponse(response: unknown, args: string[]): unknown {
if (args.includes("--raw")) return response;
const maxBodyBytes = numberOption(args, "--max-body-bytes", 60_000);
@@ -84,7 +98,7 @@ export async function runMicroserviceCommand(_config: UniDeskConfig, args: strin
if (action === "proxy") {
const id = requireId(idArg, "microservice proxy");
const path = requireProxyPath(pathArg);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`), args);
return summarizeMicroserviceProxyResponse(coreInternalFetch(`/api/microservices/${encodeId(id)}/proxy${path}`, { method: methodOption(args) }), args);
}
throw new Error("microservice command must be one of: list, status, health, proxy");
}
+222
View File
@@ -0,0 +1,222 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { runCommand } from "./command";
interface ProviderAttachOptions {
providerId: string;
masterServer: string;
envFile: string;
composeFile: string;
logDir: string;
hostSshKeyDir: string;
token: string | null;
force: boolean;
up: boolean;
}
interface ProviderAttachContainerValidation {
ok: boolean;
containerName: string;
restartPolicy: string;
restartPolicyOk: boolean;
pidMode: string;
pidModeOk: boolean;
state: string;
command: string[];
exitCode: number | null;
stderr: string;
}
function optionValue(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index === -1) return null;
const value = args[index + 1];
if (value === undefined || value.length === 0) throw new Error(`${name} requires a non-empty value`);
return value;
}
function hasFlag(args: string[], name: string): boolean {
return args.includes(name);
}
function safeProviderSlug(providerId: string): string {
return providerId.replace(/[^a-zA-Z0-9_.-]/g, "-").replace(/^-+|-+$/g, "").toLowerCase() || "provider";
}
function defaultMasterServer(config: UniDeskConfig): string {
return `http://${config.network.publicHost}/`;
}
function defaultHostSshKeyDir(): string {
const home = process.env.HOME || "/root";
return join(home, ".unidesk", "host-ssh");
}
function parseAttachOptions(config: UniDeskConfig, args: string[]): ProviderAttachOptions {
const valueOptions = new Set(["--env-file", "--compose-file", "--log-dir", "--master-server", "--master", "--host-ssh-key-dir", "--provider-token", "--token"]);
let providerId: string | undefined;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index] ?? "";
if (valueOptions.has(arg)) {
index += 1;
continue;
}
if (!arg.startsWith("--")) {
providerId = arg;
break;
}
}
if (providerId === undefined) {
throw new Error("provider attach requires provider id, for example: bun scripts/cli.ts provider attach D518");
}
const envFile = optionValue(args, "--env-file") ?? rootPath(".state", `provider-${providerId}.env`);
const composeFile = optionValue(args, "--compose-file") ?? rootPath(`provider-${providerId}.yml`);
const logDir = optionValue(args, "--log-dir") ?? rootPath("logs", `provider-${providerId}`);
return {
providerId,
masterServer: optionValue(args, "--master-server") ?? optionValue(args, "--master") ?? defaultMasterServer(config),
envFile,
composeFile,
logDir,
hostSshKeyDir: optionValue(args, "--host-ssh-key-dir") ?? defaultHostSshKeyDir(),
token: optionValue(args, "--provider-token") ?? optionValue(args, "--token"),
force: hasFlag(args, "--force"),
up: hasFlag(args, "--up"),
};
}
function envContent(options: ProviderAttachOptions): string {
const lines = [
"# Generated by: bun scripts/cli.ts provider attach",
"# Required attach inputs are intentionally limited to master server and provider id.",
`UNIDESK_MASTER_SERVER=${options.masterServer}`,
`PROVIDER_ID=${options.providerId}`,
];
if (options.token !== null) lines.push(`PROVIDER_TOKEN=${options.token}`);
return `${lines.join("\n")}\n`;
}
function composeContent(options: ProviderAttachOptions): string {
const slug = safeProviderSlug(options.providerId);
const relativeEnv = pathForCompose(options.envFile);
const relativeLogDir = pathForCompose(options.logDir);
return [
"services:",
" provider-gateway:",
` image: unidesk_provider-gateway:${slug}`,
" build:",
" context: .",
" dockerfile: src/components/provider-gateway/Dockerfile",
` container_name: unidesk-provider-gateway-${slug}`,
" restart: always",
' pid: "host"',
" env_file:",
` - ${relativeEnv}`,
" volumes:",
" - /var/run/docker.sock:/var/run/docker.sock",
" - .:/workspace:ro",
` - ${relativeLogDir}:/var/log/unidesk`,
` - ${options.hostSshKeyDir}:/run/host-ssh:ro`,
" extra_hosts:",
' - "host.docker.internal:host-gateway"',
"",
].join("\n");
}
function pathForCompose(path: string): string {
if (path.startsWith(repoRoot)) {
const relative = path.slice(repoRoot.length).replace(/^\/+/, "");
return relative.length === 0 ? "." : `./${relative}`;
}
return path;
}
function writeGeneratedFile(path: string, content: string, force: boolean): "created" | "updated" | "unchanged" | "skipped" {
if (existsSync(path)) {
const existing = readFileSync(path, "utf8");
if (existing === content) return "unchanged";
if (!force) return "skipped";
writeFileSync(path, content, "utf8");
return "updated";
}
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, content, "utf8");
return "created";
}
function inspectAttachedContainer(options: ProviderAttachOptions): ProviderAttachContainerValidation {
const containerName = `unidesk-provider-gateway-${safeProviderSlug(options.providerId)}`;
const command = [
"docker",
"inspect",
"--format",
"{{.HostConfig.RestartPolicy.Name}}\t{{.HostConfig.PidMode}}\t{{.State.Status}}",
containerName,
];
const result = runCommand(command, repoRoot);
const [restartPolicy = "", pidMode = "", state = ""] = result.stdout.trim().split("\t");
const restartPolicyOk = restartPolicy === "always";
const pidModeOk = pidMode === "host";
return {
ok: result.exitCode === 0 && restartPolicyOk && pidModeOk && state === "running",
containerName,
restartPolicy,
restartPolicyOk,
pidMode,
pidModeOk,
state,
command,
exitCode: result.exitCode,
stderr: result.stderr.trim(),
};
}
export async function runProviderCommand(config: UniDeskConfig, args: string[]): Promise<unknown> {
const [sub] = args;
if (sub !== "attach") {
throw new Error("provider requires subcommand: attach");
}
const options = parseAttachOptions(config, args.slice(1));
mkdirSync(options.logDir, { recursive: true });
mkdirSync(options.hostSshKeyDir, { recursive: true, mode: 0o700 });
const envStatus = writeGeneratedFile(options.envFile, envContent(options), options.force);
const composeStatus = writeGeneratedFile(options.composeFile, composeContent(options), options.force);
const composeCommand = [
"docker",
"compose",
"--env-file",
options.envFile,
"-f",
options.composeFile,
"-p",
`unidesk-${safeProviderSlug(options.providerId)}`,
"up",
"-d",
"--build",
"--remove-orphans",
];
const result = options.up ? runCommand(composeCommand, repoRoot) : null;
const containerValidation = options.up ? inspectAttachedContainer(options) : null;
return {
providerId: options.providerId,
masterServer: options.masterServer,
envFile: options.envFile,
envFileStatus: envStatus,
composeFile: options.composeFile,
composeFileStatus: composeStatus,
logDir: options.logDir,
hostSshKeyDir: options.hostSshKeyDir,
hostSshKeyPath: join(options.hostSshKeyDir, "id_ed25519"),
startCommand: composeCommand,
started: options.up,
result,
containerValidation,
notes: [
"The generated env file keeps only UNIDESK_MASTER_SERVER and PROVIDER_ID unless --provider-token is supplied.",
"Place a maintenance SSH private key at hostSshKeyPath if this provider should expose host.ssh.",
"When --up is used, containerValidation must show restartPolicy=always and pidMode=host before the node is accepted as durable across Docker daemon restarts.",
envStatus === "skipped" || composeStatus === "skipped" ? "Existing generated files differed and were not overwritten; rerun with --force to replace them." : "Generated files are ready.",
],
};
}
+3 -3
View File
@@ -3,7 +3,7 @@ import { type UniDeskConfig } from "./config";
import { type DebugDispatchCommand, isDebugDispatchCommand } from "./debug";
import { summarizeMicroserviceProxyResponse } from "./microservices";
import { isSshSkillDiscoveryArgs, parseSshArgs } from "./ssh";
import { codexOutputQueryAsync, codexTaskQueryAsync } from "./codex-queue";
import { codexOutputQueryAsync, codexTaskQueryAsync } from "./code-queue";
export interface RemoteCliOptions {
host: string | null;
@@ -468,7 +468,7 @@ async function remoteMicroservice(session: FrontendSession, args: string[]): Pro
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | proxy <id> <path>");
}
async function remoteCodexQueue(session: FrontendSession, args: string[]): Promise<unknown> {
async function remoteCodeQueue(session: FrontendSession, args: string[]): Promise<unknown> {
const action = args[1] ?? "task";
if (action !== "task" && action !== "summary" && action !== "show" && action !== "output") {
throw new Error("remote codex command must be: codex task <taskId> or codex output <taskId>");
@@ -559,7 +559,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
return 0;
}
if (top === "codex") {
emitRemoteJson(name, await remoteCodexQueue(session, args));
emitRemoteJson(name, await remoteCodeQueue(session, args));
return 0;
}
if (top === "ssh") {