feat: harden codex queue runtime

Add model selection, batch enqueue controls, dev-ready health checks, transcript pagination, queue watchdog recovery, and MiniMax judge JSON repair for codex-queue.
This commit is contained in:
Codex
2026-05-08 03:57:53 +00:00
parent 34652e1731
commit 41fdaba973
13 changed files with 1415 additions and 142 deletions
+1
View File
@@ -1252,6 +1252,7 @@ markStaleTasksFailed().catch((error) => logger("error", "task_timeout_sweep_fail
const apiServer = Bun.serve<WsData>({
port: config.port,
hostname: "0.0.0.0",
idleTimeout: 120,
fetch: route,
websocket: {
open(ws) {
+246 -3
View File
@@ -1217,10 +1217,69 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
.codex-queue-layout {
display: grid;
grid-template-columns: minmax(320px, 0.52fr) minmax(680px, 1.58fr);
grid-template-columns: minmax(300px, 0.52fr) minmax(0, 1.58fr);
gap: 10px;
align-items: start;
}
.codex-session-stage {
min-width: 0;
width: 100%;
}
.codex-session-stage .codex-output-panel {
width: 100%;
}
.codex-session-stage .codex-transcript {
min-height: 620px;
max-height: calc(100vh - 230px);
}
.codex-session-shell {
display: grid;
grid-template-columns: minmax(280px, 0.34fr) minmax(0, 1fr);
min-width: 0;
align-items: stretch;
}
.codex-session-shell.queue-collapsed {
grid-template-columns: minmax(0, 1fr);
}
.codex-session-sidebar {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
min-width: 0;
padding: 10px;
border-right: 1px solid var(--line);
background:
radial-gradient(circle at 0 0, rgba(78, 183, 168, 0.11), transparent 42%),
rgba(6, 10, 13, 0.72);
}
.codex-session-sidebar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.codex-session-sidebar-head > div {
display: grid;
gap: 2px;
min-width: 0;
}
.codex-session-sidebar-head span {
color: var(--muted);
font-size: 10px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.codex-session-sidebar-head strong {
min-width: 0;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-session-main {
min-width: 0;
}
.codex-left-rail,
.codex-main-stage,
.codex-detail-grid,
@@ -1229,11 +1288,29 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
display: grid;
gap: 10px;
}
.codex-compose-panel,
.codex-compose-panel .panel-body,
.codex-task-form,
.codex-task-form label {
min-width: 0;
overflow: hidden;
}
.codex-form-grid {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) 92px;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.codex-form-grid label:nth-child(2) {
grid-column: 1 / -1;
}
.codex-task-form textarea,
.codex-steer-form textarea,
.codex-form-grid input,
.codex-form-grid select {
min-width: 0;
max-width: 100%;
box-sizing: border-box;
}
.codex-task-list {
display: grid;
gap: 7px;
@@ -1242,6 +1319,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
overflow: auto;
align-content: start;
}
.codex-task-list-session {
min-height: 0;
max-height: calc(100vh - 318px);
}
.codex-task-section {
display: grid;
gap: 7px;
min-width: 0;
}
.codex-task-section + .codex-task-section {
margin-top: 6px;
padding-top: 9px;
border-top: 1px solid rgba(255,255,255,0.07);
}
.codex-task-section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 11px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.codex-task-section-head code {
color: var(--accent-2);
}
.codex-task-section-list {
display: grid;
gap: 7px;
min-width: 0;
}
.codex-task-section-empty {
margin: 0;
padding: 8px 9px;
border: 1px dashed var(--line-soft);
color: var(--muted);
font-size: 12px;
background: rgba(255,255,255,0.02);
}
.codex-task-card {
display: grid;
gap: 6px;
@@ -1282,6 +1399,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.codex-output-panel .panel-body {
padding: 0;
}
.codex-output-stack {
display: grid;
min-width: 0;
}
.codex-transcript {
min-height: 520px;
max-height: calc(100vh - 300px);
@@ -1300,6 +1421,87 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: var(--muted);
background: rgba(255,255,255,0.025);
}
.codex-transcript-item {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
gap: 8px;
padding: 9px 0;
border-bottom: 1px solid rgba(255,255,255,0.045);
}
.codex-transcript-bullet {
color: var(--accent);
font-size: 20px;
line-height: 1.1;
text-align: center;
}
.codex-transcript-main {
display: grid;
gap: 6px;
min-width: 0;
}
.codex-transcript-title {
display: flex;
align-items: center;
gap: 7px;
min-width: 0;
color: var(--text);
}
.codex-transcript-title strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-transcript-title time {
margin-left: auto;
color: var(--muted);
font-size: 10px;
white-space: nowrap;
}
.codex-transcript-title code {
max-width: 90px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--accent-2);
}
.codex-transcript-command,
.codex-transcript-body {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
border-left: 2px solid rgba(78, 183, 168, 0.26);
padding: 2px 0 2px 10px;
color: #d9e8e7;
font-size: 12px;
line-height: 1.48;
}
.codex-transcript-command {
display: block;
width: 100%;
margin-top: 2px;
color: #a7c7c3;
border-color: rgba(215, 161, 58, 0.34);
background: rgba(215, 161, 58, 0.035);
}
.codex-transcript-item.explored .codex-output-channel { color: #8fc7ee; border-color: rgba(105, 174, 232, 0.46); background: rgba(105, 174, 232, 0.08); }
.codex-transcript-item.edited .codex-output-channel { color: #b6da89; border-color: rgba(182, 218, 137, 0.42); background: rgba(182, 218, 137, 0.07); }
.codex-transcript-item.error .codex-output-channel { color: var(--danger); border-color: rgba(207, 106, 84, 0.52); background: rgba(207, 106, 84, 0.08); }
.codex-raw-output {
border-top: 1px solid var(--line);
background: rgba(6, 10, 13, 0.92);
}
.codex-raw-output summary {
cursor: pointer;
padding: 8px 12px;
color: var(--muted);
font-size: 12px;
}
.codex-raw-output > div {
max-height: 360px;
overflow: auto;
padding: 0 12px 12px;
}
.codex-output-line {
display: grid;
grid-template-columns: 130px minmax(0, 1fr);
@@ -1344,6 +1546,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.codex-detail-grid {
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
}
.codex-prompt-panel {
grid-column: 1 / -1;
}
.codex-prompt-detail {
display: grid;
gap: 9px;
min-width: 0;
}
.codex-prompt-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
min-width: 0;
}
.codex-prompt-meta span:not(.status-badge) {
max-width: 100%;
padding: 3px 7px;
border: 1px solid var(--line-soft);
color: var(--muted);
background: rgba(255,255,255,0.025);
font-size: 11px;
overflow-wrap: anywhere;
}
.codex-prompt-full {
max-height: 360px;
margin: 0;
padding: 10px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
border: 1px solid rgba(215, 161, 58, 0.28);
border-left: 3px solid rgba(215, 161, 58, 0.58);
color: #e7ded0;
background:
linear-gradient(90deg, rgba(215, 161, 58, 0.08), transparent 34%),
rgba(6, 10, 13, 0.84);
font-size: 12px;
line-height: 1.5;
}
.codex-judge-card {
display: grid;
gap: 8px;
@@ -3250,7 +3492,8 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.pipeline-oa-guarantees { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dispatch-form { grid-template-columns: 1fr 1fr; }
.dispatch-actions { align-items: center; }
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid { grid-template-columns: 1fr; }
.page-grid, .docker-layout, .monitor-layout, .findjob-grid, .findjob-hero, .pipeline-grid, .pipeline-hero, .met-grid, .met-form-grid, .codex-queue-layout, .codex-queue-hero, .codex-detail-grid, .codex-session-shell { grid-template-columns: 1fr; }
.codex-session-sidebar { border-right: 0; border-bottom: 1px solid var(--line); }
.pipeline-control-shell { grid-template-columns: 1fr; }
.pipeline-node-control { max-height: none; min-height: 0; }
.findjob-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5), .met-grid .panel:nth-child(3), .met-grid .panel:nth-child(5), .met-detail-panel { grid-column: 1; }
+400 -61
View File
@@ -33,11 +33,16 @@ async function requestJson(path: string, options: AnyRecord = {}): Promise<any>
const response = await fetch(path, { credentials: "same-origin", ...options, body, headers });
const text = await response.text();
let payload = null;
let parseFailed = false;
try {
payload = text ? JSON.parse(text) : null;
} catch {
parseFailed = true;
payload = { text };
}
if (parseFailed) {
throw new Error(`Codex Queue 返回了无效 JSON${text.length} bytes),可能是代理响应过大或被截断`);
}
if (!response.ok || payload?.ok === false) {
const message = payload?.error?.message || payload?.error || `HTTP ${response.status}`;
const error = new Error(message);
@@ -106,10 +111,62 @@ function taskRows(data: any): any[] {
return Array.isArray(data?.tasks) ? data.tasks : [];
}
function taskSortValue(task: any): number {
const time = Date.parse(String(task?.updatedAt || task?.createdAt || ""));
return Number.isFinite(time) ? time : 0;
}
function mergeTaskRows(groups: any[][], activeTaskId = ""): any[] {
const byId = new Map<string, any>();
for (const group of groups) {
for (const task of group) {
const id = String(task?.id || "");
if (id.length > 0 && !byId.has(id)) byId.set(id, task);
}
}
const statusRank: Record<string, number> = { running: 0, judging: 1, retry_wait: 2, queued: 3 };
return Array.from(byId.values()).sort((left, right) => {
const leftActive = String(left?.id || "") === activeTaskId ? 0 : 1;
const rightActive = String(right?.id || "") === activeTaskId ? 0 : 1;
if (leftActive !== rightActive) return leftActive - rightActive;
const rankDelta = (statusRank[String(left?.status || "")] ?? 9) - (statusRank[String(right?.status || "")] ?? 9);
if (rankDelta !== 0) return rankDelta;
return taskSortValue(right) - taskSortValue(left);
});
}
async function loadTaskQueue(apiBaseUrl: string, healthResult: any): Promise<any> {
const statuses = ["running", "judging", "retry_wait", "queued"];
const results = await Promise.all(statuses.map(async (status) => {
try {
return await requestJson(codexApi(apiBaseUrl, `/api/tasks?status=${encodeURIComponent(status)}&limit=80`));
} catch {
return null;
}
}));
const historyResult = await requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=160")).catch(() => null);
const queue = results.find((item) => item?.queue)?.queue || historyResult?.queue || healthResult?.queue || healthResult?.body?.queue || {};
const rows = mergeTaskRows([...results.map((item) => taskRows(item)), taskRows(historyResult)], String(queue?.activeTaskId || ""));
if (rows.length > 0) return { ok: true, queue, tasks: rows };
return requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=5"));
}
function taskOutput(task: any): any[] {
return Array.isArray(task?.output) ? task.output : [];
}
function taskTranscript(task: any): any[] {
if (Array.isArray(task?.transcript)) return task.transcript;
return taskOutput(task).map((item: any) => ({
seq: item.seq,
at: item.at,
kind: item.channel === "error" ? "error" : item.channel === "command" ? "ran" : "message",
title: item.method || item.channel || "message",
bodyPreview: String(item.text || ""),
rawSeqs: [item.seq],
}));
}
function taskAttempts(task: any): any[] {
return Array.isArray(task?.attempts) ? task.attempts : [];
}
@@ -125,6 +182,11 @@ function splitPromptTasks(prompt: string): string[] {
.filter(Boolean);
}
function repeatCountValue(value: any): number {
const count = Number(value);
return Number.isFinite(count) ? Math.max(1, Math.min(50, Math.floor(count))) : 1;
}
function channelLabel(channel: string): string {
const labels: Record<string, string> = {
system: "SYS",
@@ -139,15 +201,63 @@ function channelLabel(channel: string): string {
return labels[channel] || channel.toUpperCase();
}
function transcriptKindLabel(kind: string): string {
const labels: Record<string, string> = {
ran: "Ran",
explored: "Explored",
edited: "Edited",
plan: "Plan",
message: "Message",
system: "System",
error: "Error",
};
return labels[kind] || "Message";
}
function omittedLabel(lines: any): string {
const count = Number(lines || 0);
return Number.isFinite(count) && count > 0 ? `… +${Math.floor(count)} lines` : "";
}
function taskIsActive(task: any): boolean {
return ["running", "judging", "retry_wait"].includes(String(task?.status || ""));
}
function taskIsTerminal(task: any): boolean {
return ["succeeded", "failed", "canceled"].includes(String(task?.status || ""));
}
function taskHasDetail(task: any): boolean {
return Boolean(task?._detailLoaded)
|| (Array.isArray(task?.transcript) && task.transcript.length > 0)
|| (Array.isArray(task?.output) && task.output.length > 0)
|| (Array.isArray(task?.events) && task.events.length > 0);
}
function mergeTranscriptRows(existing: any[], incoming: any[]): any[] {
const byKey = new Map<string, any>();
for (const item of [...(Array.isArray(existing) ? existing : []), ...(Array.isArray(incoming) ? incoming : [])]) {
const key = `${Number(item?.seq ?? 0)}:${String(item?.kind || "message")}`;
byKey.set(key, item);
}
return Array.from(byKey.values()).sort((left, right) => Number(left?.seq ?? 0) - Number(right?.seq ?? 0));
}
function transcriptMaxSeq(transcript: any[]): number {
return (Array.isArray(transcript) ? transcript : []).reduce((max, item) => Math.max(max, Number(item?.seq ?? 0)), 0);
}
function countValue(counts: AnyRecord, key: string): string {
const value = Number(counts[key] ?? 0);
return Number.isFinite(value) ? String(value) : "0";
}
function codexModelOptions(queue: any, currentModel: string): string[] {
const configured = Array.isArray(queue?.codexModels) ? queue.codexModels : [];
const fallback = ["gpt-5.4-mini", "gpt-5.4", "gpt-5.5"];
return Array.from(new Set([...configured, ...fallback, currentModel].map((item) => String(item || "").trim()).filter(Boolean)));
}
function TaskCard({ task, selected, onSelect }: AnyRecord) {
const judge = task?.lastJudge || {};
return h("button", {
@@ -169,22 +279,95 @@ function TaskCard({ task, selected, onSelect }: AnyRecord) {
);
}
function Transcript({ task, autoScroll }: AnyRecord) {
function TaskListSection({ title, tasks, selectedId, onSelect, emptyText }: AnyRecord) {
const rows = Array.isArray(tasks) ? tasks : [];
return h("section", { className: "codex-task-section" },
h("div", { className: "codex-task-section-head" },
h("span", null, title),
h("code", null, String(rows.length)),
),
rows.length === 0
? h("p", { className: "codex-task-section-empty" }, emptyText)
: h("div", { className: "codex-task-section-list" }, rows.map((task: any) => h(TaskCard, {
key: task.id,
task,
selected: selectedId === task.id,
onSelect: () => onSelect(task.id),
}))),
);
}
function Transcript({ task, autoScroll, loading }: AnyRecord) {
const ref = useRef<HTMLDivElement | null>(null);
const output = taskOutput(task);
const transcript = taskTranscript(task);
useEffect(() => {
if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [autoScroll, output.length, task?.id]);
}, [autoScroll, transcript.length, task?.id]);
if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" });
if (loading && !taskHasDetail(task)) return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" },
h("div", { className: "codex-output-empty" }, "正在加载完整 session 记录..."),
);
return h("div", { className: "codex-transcript", ref, "data-testid": "codex-output" },
output.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : output.map((item: any) => h("article", { key: `${item.seq}-${item.channel}`, className: `codex-output-line ${item.channel || "system"}` },
h("div", { className: "codex-output-meta" },
h("span", { className: "codex-output-channel" }, channelLabel(String(item.channel || "system"))),
h("span", null, fmtDate(item.at)),
item.method ? h("code", null, item.method) : null,
transcript.length === 0 ? h("div", { className: "codex-output-empty" }, "等待 Codex 输出...") : transcript.map((item: any) => {
const kind = String(item.kind || "message");
const isCommand = ["ran", "explored", "edited"].includes(kind);
const commandMore = omittedLabel(item.commandOmittedLines);
const bodyMore = omittedLabel(item.bodyOmittedLines);
const commandText = String(item.commandPreview || (isCommand ? item.title || "" : ""));
return h("article", { key: `${item.seq}-${kind}`, className: `codex-transcript-item ${kind}` },
h("div", { className: "codex-transcript-bullet" }, "•"),
h("div", { className: "codex-transcript-main" },
h("div", { className: "codex-transcript-title" },
h("span", { className: "codex-output-channel" }, transcriptKindLabel(kind)),
isCommand ? null : h("strong", null, String(item.title || transcriptKindLabel(kind))),
item.status ? h("code", null, String(item.status)) : null,
h("time", null, fmtDate(item.at)),
),
commandText ? h("pre", { className: "codex-transcript-command" },
commandText,
commandMore ? `\n${commandMore}` : "",
) : null,
item.bodyPreview ? h("pre", { className: "codex-transcript-body" },
String(item.bodyPreview),
bodyMore ? `\n${bodyMore} (查看原始JSON获取完整记录)` : "",
) : null,
),
);
}),
);
}
function PromptDetail({ task }: AnyRecord) {
if (!task) return h(EmptyState, { title: "未选择任务", text: "选择队列或历史 session 后,这里显示完整 prompt、模型和工作目录。" });
const promptText = String(task?.prompt || "");
const lines = promptText.length > 0 ? promptText.split(/\r\n|\r|\n/u).length : 0;
return h("div", { className: "codex-prompt-detail", "data-testid": "codex-task-prompt-detail" },
h("div", { className: "codex-prompt-meta" },
h(StatusBadge, { status: task?.status }, task?.status || "unknown"),
h("span", null, `model=${task?.model || "--"}`),
h("span", null, `cwd=${task?.cwd || "--"}`),
h("span", null, `created=${fmtDate(task?.createdAt)}`),
h("span", null, `${lines} lines / ${promptText.length} chars`),
),
h("pre", { className: "codex-prompt-full", "data-testid": "codex-task-prompt-full" }, promptText || "空 prompt"),
);
}
function RawTranscript({ task }: AnyRecord) {
const output = taskOutput(task);
if (!task || output.length === 0) return h(EmptyState, { title: "暂无原始消息", text: "原始 Codex app-server 消息会保留在任务 JSON 中。" });
return h("details", { className: "codex-raw-output" },
h("summary", null, `原始 messages (${output.length})`),
h("div", null,
output.map((item: any) => h("article", { key: `${item.seq}-${item.channel}`, className: `codex-output-line ${item.channel || "system"}` },
h("div", { className: "codex-output-meta" },
h("span", { className: "codex-output-channel" }, channelLabel(String(item.channel || "system"))),
h("span", null, fmtDate(item.at)),
item.method ? h("code", null, item.method) : null,
),
h("pre", null, String(item.text || "")),
)),
),
h("pre", null, String(item.text || "")),
)),
);
}
@@ -215,21 +398,31 @@ function AttemptTable({ task }: AnyRecord) {
export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "codex-queue") || null;
const selectedIdRef = useRef("");
const queueLoadTokenRef = useRef(0);
const detailLoadTokenRef = useRef(0);
const detailInFlightRef = useRef<{ taskId: string; token: number; promise: Promise<void> } | null>(null);
const sessionCacheRef = useRef<Map<string, AnyRecord>>(new Map());
const [health, setHealth] = useState(null);
const [tasksData, setTasksData] = useState(null);
const [selectedId, setSelectedId] = useState("");
const [selectedTask, setSelectedTask] = useState(null);
const [selectedDetailLoading, setSelectedDetailLoading] = useState(false);
const [prompt, setPrompt] = useState("请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。");
const [model, setModel] = useState("gpt-5.4-mini");
const [cwd, setCwd] = useState("/workspace");
const [cwd, setCwd] = useState("/root/unidesk");
const [maxAttempts, setMaxAttempts] = useState(3);
const [repeatCount, setRepeatCount] = useState(1);
const [steerPrompt, setSteerPrompt] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [queueSidebarOpen, setQueueSidebarOpen] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [refreshedAt, setRefreshedAt] = useState(null);
const tasks = taskRows(tasksData);
const queuedTasks = tasks.filter((task: any) => !taskIsTerminal(task));
const historyTasks = tasks.filter((task: any) => taskIsTerminal(task));
const queue = tasksData?.queue || health?.body?.queue || health?.queue || {};
const counts = queueCounts(queue);
const activeTaskId = queue?.activeTaskId || tasks.find((task: any) => taskIsActive(task))?.id || "";
@@ -237,28 +430,122 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
const repository = service ? microserviceRepository(service) : {};
const backend = service ? microserviceBackend(service) : {};
const promptParts = useMemo(() => splitPromptTasks(prompt), [prompt]);
const enqueueItems = useMemo(() => {
const count = repeatCountValue(repeatCount);
return promptParts.flatMap((text) => Array.from({ length: count }, () => text));
}, [promptParts, repeatCount]);
const codexModels = codexModelOptions(queue, model);
const selectedCanSteer = selectedTask?.id && selectedTask?.activeTurnId && String(selectedTask?.status) === "running";
const selectedCanInterrupt = selectedTask?.id && !["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
const selectedCanRetry = selectedTask?.id && ["succeeded", "failed", "canceled"].includes(String(selectedTask?.status || ""));
async function load(preferId = selectedId): Promise<void> {
function publishCachedTask(taskId: string, patch: AnyRecord, token: number): AnyRecord {
const cached = sessionCacheRef.current.get(taskId) || {};
const existingTask = cached.task || {};
const nextTranscript = Object.prototype.hasOwnProperty.call(patch, "transcript")
? patch.transcript
: (Array.isArray(existingTask.transcript) ? existingTask.transcript : []);
const task = {
...existingTask,
...patch,
transcript: nextTranscript,
output: Array.isArray(patch.output) ? patch.output : (Array.isArray(existingTask.output) ? existingTask.output : []),
events: Array.isArray(patch.events) ? patch.events : (Array.isArray(existingTask.events) ? existingTask.events : []),
};
const entry = {
...cached,
task,
maxSeq: transcriptMaxSeq(nextTranscript),
complete: Boolean(patch._transcriptComplete ?? cached.complete),
};
sessionCacheRef.current.set(taskId, entry);
if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedTask(task);
return entry;
}
async function loadTaskDetail(taskId: string): Promise<void> {
if (!service || !taskId) return;
const token = detailLoadTokenRef.current;
const cached = sessionCacheRef.current.get(taskId);
if (cached?.task) {
setSelectedTask(cached.task);
setSelectedDetailLoading(false);
if (cached.complete && taskIsTerminal(cached.task)) return;
} else {
setSelectedDetailLoading(true);
}
const existing = detailInFlightRef.current;
if (existing?.taskId === taskId && existing.token === token) return existing.promise;
const promise = (async () => {
try {
const meta = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}?meta=1`));
if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return;
const current = sessionCacheRef.current.get(taskId);
const currentTranscript = Array.isArray(current?.task?.transcript) ? current.task.transcript : [];
const metaTask = meta?.task || {};
publishCachedTask(taskId, { ...metaTask, transcript: currentTranscript, _detailLoaded: currentTranscript.length > 0, _transcriptComplete: current?.complete }, token);
const startSeq = currentTranscript.length > 0 && !taskIsTerminal(metaTask)
? Math.max(0, transcriptMaxSeq(currentTranscript) - 1)
: transcriptMaxSeq(currentTranscript);
let afterSeq = current?.complete && taskIsTerminal(metaTask) ? transcriptMaxSeq(currentTranscript) : startSeq;
let hasMore = true;
while (hasMore) {
const chunk = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(taskId)}/transcript?afterSeq=${encodeURIComponent(String(afterSeq))}&limit=32`));
if (token !== detailLoadTokenRef.current || selectedIdRef.current !== taskId) return;
const cachedNow = sessionCacheRef.current.get(taskId);
const existingTranscript = Array.isArray(cachedNow?.task?.transcript) ? cachedNow.task.transcript : [];
const mergedTranscript = mergeTranscriptRows(existingTranscript, Array.isArray(chunk?.transcript) ? chunk.transcript : []);
const complete = Boolean(!chunk?.hasMore);
publishCachedTask(taskId, {
status: chunk?.status || metaTask.status,
updatedAt: chunk?.updatedAt || metaTask.updatedAt,
transcript: mergedTranscript,
_detailLoaded: complete || mergedTranscript.length > 0,
_transcriptComplete: complete,
}, token);
hasMore = Boolean(chunk?.hasMore);
afterSeq = Number(chunk?.nextAfterSeq ?? transcriptMaxSeq(mergedTranscript));
if (!hasMore) break;
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
} finally {
if (detailInFlightRef.current?.taskId === taskId && detailInFlightRef.current?.token === token) detailInFlightRef.current = null;
if (token === detailLoadTokenRef.current && selectedIdRef.current === taskId) setSelectedDetailLoading(false);
}
})();
detailInFlightRef.current = { taskId, token, promise };
await promise;
}
async function load(preferId = selectedIdRef.current): Promise<void> {
if (!service) return;
const [healthResult, tasksResult] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/codex-queue/health`),
requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=80")),
]);
const token = queueLoadTokenRef.current + 1;
queueLoadTokenRef.current = token;
const requestedId = String(preferId || selectedIdRef.current || "");
const healthResult = await requestJson(`${apiBaseUrl}/microservices/codex-queue/health`);
const tasksResult = await loadTaskQueue(apiBaseUrl, healthResult);
if (token !== queueLoadTokenRef.current) return;
setHealth(healthResult);
setTasksData(tasksResult);
const rows = taskRows(tasksResult);
const nextId = preferId && rows.some((task: any) => task.id === preferId)
? preferId
const latestRequestedId = requestedId || selectedIdRef.current;
const nextId = latestRequestedId && rows.some((task: any) => task.id === latestRequestedId)
? latestRequestedId
: (tasksResult?.queue?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || rows[0]?.id || "");
const previousId = selectedIdRef.current;
if (previousId !== nextId) detailLoadTokenRef.current += 1;
selectedIdRef.current = nextId;
setSelectedId(nextId);
if (nextId) {
const detail = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(nextId)}`));
setSelectedTask(detail?.task || null);
} else {
const row = rows.find((task: any) => task.id === nextId);
if (row) {
const cached = sessionCacheRef.current.get(nextId);
if (cached?.task) sessionCacheRef.current.set(nextId, { ...cached, task: { ...row, ...cached.task, status: row.status, updatedAt: row.updatedAt } });
}
if (nextId) void loadTaskDetail(nextId).catch((err) => setError(errorMessage(err, "加载 Codex session 详情失败")));
else {
detailLoadTokenRef.current += 1;
setSelectedTask(null);
setSelectedDetailLoading(false);
}
setRefreshedAt(new Date());
}
@@ -278,12 +565,13 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
async function enqueue(event: any): Promise<void> {
event.preventDefault();
await guarded(async () => {
if (promptParts.length === 0) throw new Error("prompt 不能为空");
const body = promptParts.length === 1
? { prompt: promptParts[0], model, cwd, maxAttempts: Number(maxAttempts) }
: { tasks: promptParts.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) };
const result = await requestJson(codexApi(apiBaseUrl, promptParts.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body });
if (enqueueItems.length === 0) throw new Error("prompt 不能为空");
const body = enqueueItems.length === 1
? { prompt: enqueueItems[0], model, cwd, maxAttempts: Number(maxAttempts) }
: { tasks: enqueueItems.map((text) => ({ prompt: text, model, cwd, maxAttempts: Number(maxAttempts) })) };
const result = await requestJson(codexApi(apiBaseUrl, enqueueItems.length === 1 ? "/api/tasks" : "/api/tasks/batch"), { method: "POST", body });
const firstId = result?.tasks?.[0]?.id || "";
selectedIdRef.current = firstId;
await load(firstId);
}, "Codex 任务入队失败");
}
@@ -314,17 +602,85 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
}, "重新入队失败");
}
function selectTask(taskId: string): void {
selectedIdRef.current = taskId;
detailLoadTokenRef.current += 1;
setSelectedId(taskId);
const cached = sessionCacheRef.current.get(taskId);
if (cached?.task) {
setSelectedTask(cached.task);
setSelectedDetailLoading(false);
} else {
setSelectedDetailLoading(true);
const row = tasks.find((task: any) => task.id === taskId);
if (row) setSelectedTask(row);
else setSelectedTask(null);
}
void load(taskId).catch((err) => setError(errorMessage(err, "切换 Codex session 失败")));
}
useEffect(() => {
void guarded(() => load(), "Codex Queue 加载失败");
void guarded(() => load(selectedIdRef.current), "Codex Queue 加载失败");
}, [service?.id, service?.runtime?.providerStatus]);
useEffect(() => {
if (!service) return undefined;
const timer = window.setInterval(() => {
void load(selectedId).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败")));
void load(selectedIdRef.current).catch((err) => setError(errorMessage(err, "Codex Queue 轮询失败")));
}, 1500);
return () => window.clearInterval(timer);
}, [service?.id, selectedId]);
}, [service?.id]);
const taskListContent = tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : [
h(TaskListSection, {
key: "active",
title: "运行 / 排队",
tasks: queuedTasks,
selectedId,
emptyText: "当前没有运行或排队任务。",
onSelect: selectTask,
}),
h(TaskListSection, {
key: "history",
title: "历史 session",
tasks: historyTasks,
selectedId,
emptyText: "最近没有完成、失败或取消的 session。",
onSelect: selectTask,
}),
];
const sessionPanel = h(Panel, {
title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出",
eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => setQueueSidebarOpen((value: boolean) => !value), "data-testid": "codex-queue-sidebar-toggle" }, queueSidebarOpen ? "收起队列" : "显示队列"),
h("label", { className: "inline-check" }, h("input", { type: "checkbox", checked: autoScroll, onChange: (event: any) => setAutoScroll(Boolean(event.target.checked)) }), "自动滚动"),
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanInterrupt || busy, onClick: () => void interrupt(), "data-testid": "codex-interrupt-button" }, "打断"),
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanRetry || busy, onClick: () => void retry() }, "重试"),
selectedTask ? h(RawButton, { title: "Codex Task", data: selectedTask, onOpen: onRaw, testId: "raw-codex-task" }) : null,
),
className: "codex-output-panel",
},
h("div", { className: `codex-session-shell ${queueSidebarOpen ? "" : "queue-collapsed"}` },
queueSidebarOpen ? h("aside", { className: "codex-session-sidebar", "data-testid": "codex-session-sidebar" },
h("div", { className: "codex-session-sidebar-head" },
h("div", null,
h("span", null, "Queue"),
h("strong", null, `${tasks.length} sessions`),
),
h("button", { type: "button", className: "ghost-btn", onClick: () => setQueueSidebarOpen(false) }, "收起"),
),
h("div", { className: "codex-task-list codex-task-list-session" }, taskListContent),
) : null,
h("div", { className: "codex-session-main" },
h("div", { className: "codex-output-stack" },
h(Transcript, { task: selectedTask, autoScroll, loading: selectedDetailLoading }),
h(RawTranscript, { task: selectedTask }),
),
),
),
);
if (!service) return h(EmptyState, { title: "Codex Queue 未登记", text: "请在 config.json 的 microservices 中登记 id=codex-queue" });
@@ -350,7 +706,7 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
h("div", { className: "microservice-ref-card" },
h("span", null, "Codex"),
h("strong", null, queue?.defaultModel || "gpt-5.4-mini"),
h("code", null, "codex app-server --listen stdio://"),
h("code", null, `models: ${codexModels.join(" / ")}`),
),
h("div", { className: "microservice-ref-card" },
h("span", null, "Backend"),
@@ -367,50 +723,33 @@ export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: An
h(MetricCard, { label: "异常/取消", value: String(Number(counts.failed || 0) + Number(counts.canceled || 0)), hint: "terminal non-success", tone: Number(counts.failed || 0) > 0 ? "fail" : "" }),
h(MetricCard, { label: "最近刷新", value: refreshedAt ? fmtClock(refreshedAt) : "--", hint: "1.5s polling" }),
),
h("div", { className: "codex-session-stage" }, sessionPanel),
h("div", { className: "codex-queue-layout" },
h("div", { className: "codex-left-rail" },
h(Panel, { title: "提交任务", eyebrow: promptParts.length > 1 ? `${promptParts.length} tasks` : "Single or Batch", className: "codex-compose-panel" },
h(Panel, { title: "提交任务", eyebrow: enqueueItems.length > 1 ? `${enqueueItems.length} tasks` : "Single or Batch", className: "codex-compose-panel" },
h("form", { className: "codex-task-form", onSubmit: enqueue, "data-testid": "codex-queue-task-form" },
h("label", null, "Prompt / 多任务用单独一行 --- 分隔",
h("textarea", { value: prompt, rows: 8, onChange: (event: any) => setPrompt(event.target.value), placeholder: "写入 Codex 任务;多个任务之间用 --- 分隔。" }),
),
h("div", { className: "codex-form-grid" },
h("label", null, "模型", h("input", { value: model, onChange: (event: any) => setModel(event.target.value), placeholder: "gpt-5.4-mini" })),
h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: "/workspace" })),
h("label", null, "模型",
h("select", { value: model, onChange: (event: any) => setModel(event.target.value), "data-testid": "codex-model-select" },
codexModels.map((name) => h("option", { key: name, value: name }, name)),
),
),
h("label", null, "工作目录", h("input", { value: cwd, onChange: (event: any) => setCwd(event.target.value), placeholder: queue?.defaultWorkdir || "/root/unidesk" })),
h("label", null, "最大尝试", h("input", { type: "number", min: 1, max: 10, value: maxAttempts, onChange: (event: any) => setMaxAttempts(Number(event.target.value)) })),
h("label", null, "入队份数", h("input", { type: "number", min: 1, max: 50, value: repeatCount, onChange: (event: any) => setRepeatCount(Number(event.target.value)), "data-testid": "codex-repeat-count-input" })),
),
h("button", { type: "submit", className: "primary-btn", disabled: busy || promptParts.length === 0 }, promptParts.length > 1 ? `批量入队 ${promptParts.length} 个任务` : "入队并运行"),
),
),
h(Panel, { title: "队列", eyebrow: `${tasks.length} visible` },
h("div", { className: "codex-task-list" },
tasks.length === 0 ? h(EmptyState, { title: "队列为空", text: "提交一个任务后,Codex 会串行执行并保存输出。" }) : tasks.map((task: any) => h(TaskCard, {
key: task.id,
task,
selected: selectedId === task.id,
onSelect: () => {
setSelectedId(task.id);
void load(task.id);
},
})),
h("button", { type: "submit", className: "primary-btn", disabled: busy || enqueueItems.length === 0, "data-testid": "codex-enqueue-button" }, enqueueItems.length > 1 ? `批量入队 ${enqueueItems.length} 个任务` : "入队并运行"),
),
),
),
h("div", { className: "codex-main-stage" },
h(Panel, {
title: selectedTask ? `Session ${String(selectedTask.id).slice(0, 22)}` : "Session 输出",
eyebrow: selectedTask ? `${selectedTask.status} / ${selectedTask.model}` : "Codex CLI-like stream",
actions: h("div", { className: "panel-actions" },
h("label", { className: "inline-check" }, h("input", { type: "checkbox", checked: autoScroll, onChange: (event: any) => setAutoScroll(Boolean(event.target.checked)) }), "自动滚动"),
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanInterrupt || busy, onClick: () => void interrupt(), "data-testid": "codex-interrupt-button" }, "打断"),
h("button", { type: "button", className: "ghost-btn", disabled: !selectedCanRetry || busy, onClick: () => void retry() }, "重试"),
selectedTask ? h(RawButton, { title: "Codex Task", data: selectedTask, onOpen: onRaw, testId: "raw-codex-task" }) : null,
),
className: "codex-output-panel",
},
h(Transcript, { task: selectedTask, autoScroll }),
),
h("div", { className: "codex-detail-grid" },
h(Panel, { title: "Prompt 全量", eyebrow: selectedTask ? String(selectedTask.id) : "selected task", className: "codex-prompt-panel" },
h(PromptDetail, { task: selectedTask }),
),
h(Panel, { title: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" },
h("form", { className: "codex-steer-form", onSubmit: steer },
h("label", null, "追加 prompt",
+1
View File
@@ -295,6 +295,7 @@ function isStaticAssetPath(pathname: string): boolean {
const server = Bun.serve({
port: config.port,
hostname: "0.0.0.0",
idleTimeout: 120,
async fetch(req) {
const url = new URL(req.url);
logger("debug", "request", { path: url.pathname });
@@ -1,7 +1,36 @@
FROM oven/bun:1-debian
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl git bash ripgrep procps python3 make g++ bubblewrap docker.io npm \
&& apt-get install -y --no-install-recommends \
bash \
bubblewrap \
ca-certificates \
curl \
docker-cli \
docker-compose \
file \
g++ \
gcc \
git \
gzip \
jq \
make \
npm \
openssh-client \
patch \
pkg-config \
procps \
python3 \
python3-pip \
ripgrep \
rsync \
tar \
tini \
unzip \
xz-utils \
&& mkdir -p /usr/local/lib/docker/cli-plugins /root/.docker/cli-plugins \
&& ln -sf /usr/bin/docker-compose /usr/local/lib/docker/cli-plugins/docker-compose \
&& ln -sf /usr/bin/docker-compose /root/.docker/cli-plugins/docker-compose \
&& npm install -g @openai/codex@0.128.0 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
@@ -12,4 +41,5 @@ COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json
COPY src/components/microservices/codex-queue/src ./src
EXPOSE 4222
ENTRYPOINT ["tini", "--"]
CMD ["bun", "run", "src/index.ts"]
File diff suppressed because it is too large Load Diff