feat: integrate codex queue and pipeline oa flow

- add Codex Queue microservice/frontend integration and related deployment docs
- document 100% Pipeline OA event-flow requirements and E2E gates
- harden Pipeline frontend Gantt/timeline E2E assertions and rendering
This commit is contained in:
Codex
2026-05-07 18:11:43 +00:00
parent 2aaf0447a6
commit 79166574e8
27 changed files with 3209 additions and 123 deletions
+351 -15
View File
@@ -1110,7 +1110,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.result-card dd { margin: 0; }
.result-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
.microservice-page, .findjob-page, .pipeline-page, .met-page {
.microservice-page, .findjob-page, .pipeline-page, .met-page, .codex-queue-page {
display: grid;
gap: 10px;
}
@@ -1159,7 +1159,6 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
gap: 10px;
align-items: start;
}
.pipeline-grid .panel:nth-child(3), .pipeline-grid .panel:nth-child(5) { grid-column: 1 / -1; }
.pipeline-grid .pipeline-wide-panel { grid-column: 1 / -1; }
.met-grid {
display: grid;
@@ -1205,6 +1204,173 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: var(--accent-2);
font-size: 12px;
}
.codex-queue-hero {
display: grid;
grid-template-columns: minmax(300px, 1.35fr) minmax(260px, 0.8fr) minmax(220px, 0.65fr);
gap: 8px;
align-items: stretch;
}
.codex-queue-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(140px, 1fr));
gap: 8px;
}
.codex-queue-layout {
display: grid;
grid-template-columns: minmax(320px, 0.52fr) minmax(680px, 1.58fr);
gap: 10px;
align-items: start;
}
.codex-left-rail,
.codex-main-stage,
.codex-detail-grid,
.codex-task-form,
.codex-steer-form {
display: grid;
gap: 10px;
}
.codex-form-grid {
display: grid;
grid-template-columns: minmax(120px, 0.8fr) minmax(180px, 1fr) 92px;
gap: 8px;
}
.codex-task-list {
display: grid;
gap: 7px;
max-height: calc(100vh - 460px);
min-height: 180px;
overflow: auto;
align-content: start;
}
.codex-task-card {
display: grid;
gap: 6px;
width: 100%;
padding: 9px;
border: 1px solid var(--line-soft);
color: var(--muted);
background:
linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 38%),
rgba(10, 16, 21, 0.74);
text-align: left;
}
.codex-task-card:hover,
.codex-task-card.selected {
color: var(--text);
border-color: var(--accent-2);
background:
linear-gradient(135deg, rgba(78, 183, 168, 0.16), transparent 42%),
rgba(12, 24, 28, 0.9);
}
.codex-task-card-head,
.codex-task-meta,
.codex-judge-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.codex-task-card strong {
overflow-wrap: anywhere;
color: var(--text);
}
.codex-task-meta,
.codex-judge-line {
color: var(--muted);
font-size: 11px;
}
.codex-output-panel .panel-body {
padding: 0;
}
.codex-transcript {
min-height: 520px;
max-height: calc(100vh - 300px);
overflow: auto;
padding: 12px;
background:
linear-gradient(rgba(78, 183, 168, 0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(78, 183, 168, 0.026) 1px, transparent 1px),
radial-gradient(circle at top right, rgba(215, 161, 58, 0.10), transparent 34%),
#060a0d;
background-size: 24px 24px, 24px 24px, auto, auto;
}
.codex-output-empty {
padding: 24px;
border: 1px dashed var(--line);
color: var(--muted);
background: rgba(255,255,255,0.025);
}
.codex-output-line {
display: grid;
grid-template-columns: 130px minmax(0, 1fr);
gap: 10px;
padding: 8px 0;
border-bottom: 1px solid rgba(255,255,255,0.045);
}
.codex-output-meta {
display: grid;
align-content: start;
gap: 4px;
color: var(--muted);
font-size: 10px;
}
.codex-output-meta code {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.codex-output-channel {
width: max-content;
padding: 2px 6px;
border: 1px solid rgba(78, 183, 168, 0.42);
color: var(--accent-2);
background: rgba(78, 183, 168, 0.08);
letter-spacing: 0.08em;
}
.codex-output-line.user .codex-output-channel { color: var(--accent); border-color: rgba(215, 161, 58, 0.48); background: rgba(215, 161, 58, 0.08); }
.codex-output-line.error .codex-output-channel { color: var(--danger); border-color: rgba(207, 106, 84, 0.52); background: rgba(207, 106, 84, 0.08); }
.codex-output-line.command .codex-output-channel { color: #8fc7ee; border-color: rgba(105, 174, 232, 0.46); background: rgba(105, 174, 232, 0.08); }
.codex-output-line.diff .codex-output-channel { color: #b6da89; border-color: rgba(182, 218, 137, 0.42); background: rgba(182, 218, 137, 0.07); }
.codex-output-line pre {
margin: 0;
white-space: pre-wrap;
overflow-wrap: anywhere;
color: #d9e8e7;
font-size: 12px;
line-height: 1.48;
}
.codex-output-line.reasoning pre { color: #9fb5b8; font-style: italic; }
.codex-detail-grid {
grid-template-columns: minmax(320px, 1fr) minmax(320px, 1fr);
}
.codex-judge-card {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid var(--line-soft);
background: var(--panel-3);
}
.codex-judge-card p {
margin: 0;
color: var(--muted);
}
.codex-attempt-table {
max-height: 260px;
}
.inline-check {
display: inline-flex;
gap: 5px;
align-items: center;
color: var(--muted);
}
.inline-check input {
width: auto;
}
.mono-text {
font-family: "SFMono-Regular", "Consolas", monospace;
color: var(--muted);
}
.met-control-strip, .met-tabs {
display: flex;
flex-wrap: wrap;
@@ -1797,9 +1963,15 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
opacity: 0.82;
}
.pipeline-gantt-arrow.monitor,
.pipeline-gantt-arrow.guide {
.pipeline-gantt-arrow.guide,
.pipeline-gantt-arrow.observe {
stroke: var(--accent-2);
}
.pipeline-gantt-arrow.observation {
opacity: 0.92;
stroke-width: 1.9;
stroke-dasharray: 3 4;
}
.pipeline-gantt-arrow.webui {
stroke: #69aee8;
}
@@ -1859,7 +2031,20 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: #ffe3db;
}
.pipeline-gantt-bar.running {
animation: ganttPulse 1.8s ease-in-out infinite;
border-color: rgba(105, 174, 232, 0.95);
background:
linear-gradient(180deg, rgba(105, 174, 232, 0.95), rgba(35, 94, 133, 0.86)),
#07131d;
box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 18px rgba(105, 174, 232, 0.38);
animation: ganttPulse 1.25s ease-in-out infinite;
}
.pipeline-gantt-bar.running.live::after {
content: "";
position: absolute;
inset: -18px 0;
background: linear-gradient(180deg, transparent, rgba(255,255,255,0.82), transparent);
opacity: 0.7;
animation: ganttLiveSweep 1.4s linear infinite;
}
.pipeline-gantt-bar.selected {
width: 10px;
@@ -2054,6 +2239,46 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
max-width: 100%;
overflow-wrap: anywhere;
}
.pipeline-oa-panel {
display: grid;
gap: 10px;
}
.pipeline-oa-guarantees {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 7px;
}
.pipeline-oa-guarantee {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 7px;
align-items: center;
min-width: 0;
padding: 7px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.026);
}
.pipeline-oa-guarantee.ok {
border-color: rgba(78, 183, 168, 0.22);
}
.pipeline-oa-guarantee.warn {
border-color: rgba(215, 161, 58, 0.28);
}
.pipeline-oa-guarantee strong,
.pipeline-oa-guarantee span {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pipeline-oa-guarantee strong {
color: var(--text);
}
.pipeline-oa-guarantee span {
color: var(--muted);
font-size: 11px;
}
.pipeline-attempt-card {
display: grid;
gap: 8px;
@@ -2150,12 +2375,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
.pipeline-opencode-flow::before {
content: "";
position: absolute;
left: 3px;
top: 6px;
bottom: 8px;
width: 1px;
background: linear-gradient(180deg, rgba(78, 183, 168, 0.56), rgba(215, 161, 58, 0.34), rgba(105, 174, 232, 0.18));
display: none;
}
.pipeline-opencode-step {
position: relative;
@@ -2607,8 +2827,12 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
}
}
@keyframes ganttPulse {
0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(215, 161, 58, 0.28); }
50% { box-shadow: 0 0 0 1px rgba(215, 161, 58, 0.52), 0 0 20px rgba(215, 161, 58, 0.48); }
0%, 100% { box-shadow: 0 0 0 1px rgba(0,0,0,0.32), 0 0 12px rgba(105, 174, 232, 0.28); }
50% { box-shadow: 0 0 0 1px rgba(105, 174, 232, 0.62), 0 0 24px rgba(105, 174, 232, 0.58); }
}
@keyframes ganttLiveSweep {
0% { transform: translateY(-48px); }
100% { transform: translateY(48px); }
}
.pipeline-node-control {
min-width: 0;
@@ -2773,6 +2997,114 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
color: var(--muted);
font-size: 12px;
}
.pipeline-score-board {
display: grid;
gap: 8px;
}
.pipeline-score-card, .pipeline-score-empty {
display: grid;
gap: 8px;
min-width: 0;
padding: 10px;
border: 1px solid var(--line-soft);
background:
linear-gradient(135deg, rgba(78, 183, 168, 0.08), transparent 36%),
var(--panel-3);
}
.pipeline-score-empty strong {
color: var(--accent);
}
.pipeline-score-empty span {
color: var(--muted);
}
.pipeline-score-head {
display: flex;
justify-content: space-between;
gap: 10px;
align-items: flex-start;
}
.pipeline-score-head div {
display: grid;
gap: 2px;
min-width: 0;
}
.pipeline-score-head span {
overflow: hidden;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.12em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.pipeline-score-head strong {
color: var(--accent);
font-size: 30px;
line-height: 1;
}
.pipeline-score-meter {
height: 7px;
overflow: hidden;
border: 1px solid rgba(255,255,255,0.08);
background: rgba(0,0,0,0.24);
}
.pipeline-score-meter span {
display: block;
height: 100%;
min-width: 2px;
max-width: 100%;
background: linear-gradient(90deg, var(--accent-2), var(--accent));
}
.pipeline-score-card.failed .pipeline-score-meter span { background: linear-gradient(90deg, var(--danger), var(--accent)); }
.pipeline-score-card.running .pipeline-score-meter span { background: linear-gradient(90deg, var(--accent), var(--accent-2)); }
.pipeline-score-facts, .pipeline-score-items {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.pipeline-score-facts span, .pipeline-score-badge, .pipeline-score-item {
padding: 3px 7px;
border: 1px solid var(--line-soft);
background: rgba(255,255,255,0.03);
color: var(--muted);
font-size: 11px;
}
.pipeline-score-badge {
text-transform: uppercase;
}
.pipeline-score-badge.succeeded, .pipeline-score-item.passed {
border-color: rgba(78, 183, 168, 0.55);
color: var(--accent-2);
}
.pipeline-score-badge.failed, .pipeline-score-item.failed {
border-color: rgba(207, 106, 84, 0.58);
color: var(--danger);
}
.pipeline-score-badge.running {
border-color: rgba(215, 161, 58, 0.58);
color: var(--accent);
}
.pipeline-score-item {
display: grid;
gap: 1px;
min-width: 62px;
}
.pipeline-score-item b {
color: var(--text);
font-size: 12px;
}
.pipeline-score-item small {
color: currentColor;
font-size: 9px;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.pipeline-score-error {
margin: 0;
color: var(--danger);
font-size: 12px;
line-height: 1.45;
}
.component-stratum span {
color: var(--muted);
font-size: 10px;
@@ -2915,9 +3247,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
@media (max-width: 1120px) {
.metric-grid, .policy-grid, .security-board, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.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 { 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 { grid-template-columns: 1fr; }
.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; }
@@ -2932,6 +3265,7 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
.pipeline-control-evidence-grid,
.pipeline-evidence-row,
.pipeline-gantt-detail-layout,
.pipeline-oa-guarantees,
.pipeline-kv-grid,
.pipeline-field-list {
grid-template-columns: 1fr;
@@ -3034,8 +3368,10 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent-2); }
padding: 4px 9px;
white-space: nowrap;
}
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv { grid-template-columns: 1fr; }
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero { grid-template-columns: 1fr; align-items: start; }
.metric-grid, .policy-grid, .security-board, .dispatch-form, .docker-metrics, .monitor-chart-grid, .monitor-summary-grid, .gateway-record-grid, .met-detail-kv, .codex-queue-metrics, .codex-form-grid { grid-template-columns: 1fr; }
.compact-row, .heartbeat-row, .log-row, .endpoint-list article, .volume-route, .findjob-hero, .pipeline-hero, .codex-queue-hero { grid-template-columns: 1fr; align-items: start; }
.codex-output-line { grid-template-columns: 1fr; }
.codex-transcript { min-height: 360px; }
.process-resource-head {
align-items: stretch;
flex-direction: column;
+3
View File
@@ -1,5 +1,6 @@
import React from "react";
import { createRoot } from "react-dom/client";
import { CodexQueuePage } from "./codex-queue";
import { FindJobPage } from "./findjob";
import { MetNonlinearPage } from "./met-nonlinear";
import { canonicalizeKnownRoute, createRouteRegistry, DEFAULT_ACTIVE_TABS, MODULES, pathForTarget, resolveRouteTarget } from "./navigation";
@@ -1221,6 +1222,7 @@ function MicroserviceCatalogPage({ microservices, onRaw, onNavigate }: AnyRecord
service.id === "pipeline" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "pipeline"), "data-testid": "open-pipeline-button" }, "打开") : null,
service.id === "todo-note" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "todo-note"), "data-testid": "open-todo-note-button" }, "打开") : null,
service.id === "met-nonlinear" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "met-nonlinear"), "data-testid": "open-met-nonlinear-button" }, "打开") : null,
service.id === "codex-queue" ? h("button", { type: "button", className: "ghost-btn", onClick: () => onNavigate("apps", "codex-queue"), "data-testid": "open-codex-queue-button" }, "打开") : null,
h(RawButton, { title: `Microservice ${service.id}`, data: service, onOpen: onRaw }),
),
),
@@ -1481,6 +1483,7 @@ function WorkArea({ activeModule, activeTab, data, session, refresh, onRaw, onNa
if (activeModule === "apps" && activeTab === "findjob") return h(FindJobPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "pipeline") return h(PipelinePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "met-nonlinear") return h(MetNonlinearPage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "apps" && activeTab === "codex-queue") return h(CodexQueuePage, { microservices: data.microservices, onRaw, apiBaseUrl: cfg.apiBaseUrl });
if (activeModule === "config" && activeTab === "topology") return h(TopologyPage, { data });
if (activeModule === "config" && activeTab === "auth") return h(AuthPage, { session });
if (activeModule === "config" && activeTab === "security") return h(SecurityPage);
+435
View File
@@ -0,0 +1,435 @@
import React from "react";
type AnyRecord = Record<string, any>;
const h = React.createElement;
const { useEffect, useMemo, useRef } = React;
const useState: any = React.useState;
function errorMessage(error: unknown, fallback = "操作失败"): string {
return error instanceof Error ? error.message : String(error || fallback);
}
function fmtDate(value: any): string {
if (!value) return "--";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", { hour12: false });
}
function fmtClock(value: Date): string {
return value.toLocaleTimeString("zh-CN", { hour12: false });
}
function shortText(value: any, max = 180): string {
const text = String(value || "").replace(/\s+/gu, " ").trim();
return text.length > max ? `${text.slice(0, max - 1)}` : text;
}
async function requestJson(path: string, options: AnyRecord = {}): Promise<any> {
const headers = new Headers(options.headers || {});
const body = options.body && typeof options.body !== "string" ? JSON.stringify(options.body) : options.body;
if (body && !headers.has("content-type")) headers.set("content-type", "application/json");
const response = await fetch(path, { credentials: "same-origin", ...options, body, headers });
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = { text };
}
if (!response.ok || payload?.ok === false) {
const message = payload?.error?.message || payload?.error || `HTTP ${response.status}`;
const error = new Error(message);
(error as Error & { status?: number }).status = response.status;
throw error;
}
return payload;
}
function StatusBadge({ status, children }: AnyRecord) {
const normalized = String(status || "unknown").toLowerCase();
return h("span", { className: `status-badge ${normalized}` }, children || status || "unknown");
}
function MetricCard({ label, value, hint, tone }: AnyRecord) {
return h("article", { className: `metric-card ${tone || ""}` },
h("div", { className: "metric-label" }, label),
h("div", { className: "metric-value" }, value),
h("div", { className: "metric-hint" }, hint),
);
}
function Panel({ title, eyebrow, actions, children, className }: AnyRecord) {
return h("section", { className: `panel ${className || ""}` },
h("div", { className: "panel-head" },
h("div", null,
eyebrow ? h("p", { className: "panel-eyebrow" }, eyebrow) : null,
h("h2", null, title),
),
actions ? h("div", { className: "panel-actions" }, actions) : null,
),
h("div", { className: "panel-body" }, children),
);
}
function RawButton({ title, data, onOpen, testId }: AnyRecord) {
return h("button", {
type: "button",
className: "ghost-btn",
"data-testid": testId,
onClick: () => onOpen(title, data),
}, "查看原始JSON");
}
function EmptyState({ title, text }: AnyRecord) {
return h("div", { className: "empty-state" }, h("strong", null, title), h("span", null, text));
}
function microserviceRuntime(service: any): AnyRecord {
return service?.runtime && typeof service.runtime === "object" && !Array.isArray(service.runtime) ? service.runtime : {};
}
function microserviceBackend(service: any): AnyRecord {
return service?.backend && typeof service.backend === "object" && !Array.isArray(service.backend) ? service.backend : {};
}
function microserviceRepository(service: any): AnyRecord {
return service?.repository && typeof service.repository === "object" && !Array.isArray(service.repository) ? service.repository : {};
}
function codexApi(apiBaseUrl: string, path: string): string {
return `${apiBaseUrl}/microservices/codex-queue/proxy${path}`;
}
function taskRows(data: any): any[] {
return Array.isArray(data?.tasks) ? data.tasks : [];
}
function taskOutput(task: any): any[] {
return Array.isArray(task?.output) ? task.output : [];
}
function taskAttempts(task: any): any[] {
return Array.isArray(task?.attempts) ? task.attempts : [];
}
function queueCounts(queue: any): AnyRecord {
return queue?.counts && typeof queue.counts === "object" && !Array.isArray(queue.counts) ? queue.counts : {};
}
function splitPromptTasks(prompt: string): string[] {
return prompt
.split(/^\s*---+\s*$/gmu)
.map((part) => part.trim())
.filter(Boolean);
}
function channelLabel(channel: string): string {
const labels: Record<string, string> = {
system: "SYS",
user: "YOU",
assistant: "GPT",
reasoning: "THINK",
command: "CMD",
diff: "DIFF",
tool: "TOOL",
error: "ERR",
};
return labels[channel] || channel.toUpperCase();
}
function taskIsActive(task: any): boolean {
return ["running", "judging", "retry_wait"].includes(String(task?.status || ""));
}
function countValue(counts: AnyRecord, key: string): string {
const value = Number(counts[key] ?? 0);
return Number.isFinite(value) ? String(value) : "0";
}
function TaskCard({ task, selected, onSelect }: AnyRecord) {
const judge = task?.lastJudge || {};
return h("button", {
type: "button",
className: `codex-task-card ${selected ? "selected" : ""}`,
onClick: onSelect,
"data-testid": `codex-task-${task?.id || "unknown"}`,
},
h("div", { className: "codex-task-card-head" },
h(StatusBadge, { status: task?.status }, task?.status || "unknown"),
h("span", { className: "mono-text" }, `${task?.currentAttempt || 0}/${task?.maxAttempts || 0}`),
),
h("strong", null, shortText(task?.prompt, 120) || "空任务"),
h("div", { className: "codex-task-meta" },
h("span", null, task?.model || "--"),
h("span", null, fmtDate(task?.updatedAt)),
),
judge?.decision ? h("div", { className: "codex-judge-line" }, `judge=${judge.decision} ${Math.round(Number(judge.confidence || 0) * 100)}%`) : null,
);
}
function Transcript({ task, autoScroll }: AnyRecord) {
const ref = useRef<HTMLDivElement | null>(null);
const output = taskOutput(task);
useEffect(() => {
if (autoScroll && ref.current) ref.current.scrollTop = ref.current.scrollHeight;
}, [autoScroll, output.length, task?.id]);
if (!task) return h(EmptyState, { title: "未选择任务", text: "从左侧队列选择任务,或提交新 Codex 任务。" });
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,
),
h("pre", null, String(item.text || "")),
)),
);
}
function AttemptTable({ task }: AnyRecord) {
const attempts = taskAttempts(task).slice().reverse();
if (attempts.length === 0) return h(EmptyState, { title: "尚无 attempt", text: "任务开始运行后,这里会记录 Codex 终态、传输中断和 stderr tail。" });
return h("div", { className: "table-wrap codex-attempt-table" },
h("table", null,
h("thead", null, h("tr", null,
h("th", null, "#"),
h("th", null, "模式"),
h("th", null, "终态"),
h("th", null, "传输"),
h("th", null, "退出"),
h("th", null, "完成时间"),
)),
h("tbody", null, attempts.map((attempt: any) => h("tr", { key: `${attempt.index}-${attempt.startedAt}` },
h("td", null, attempt.index),
h("td", null, attempt.mode),
h("td", null, h(StatusBadge, { status: attempt.terminalStatus || "unknown" }, attempt.terminalStatus || "unknown")),
h("td", null, attempt.transportClosedBeforeTerminal ? h(StatusBadge, { status: "failed" }, "closed-before-terminal") : h(StatusBadge, { status: "succeeded" }, "normal")),
h("td", null, `code=${attempt.appServerExitCode ?? "--"} signal=${attempt.appServerSignal ?? "--"}`),
h("td", null, fmtDate(attempt.finishedAt)),
))),
),
);
}
export function CodexQueuePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "codex-queue") || null;
const [health, setHealth] = useState(null);
const [tasksData, setTasksData] = useState(null);
const [selectedId, setSelectedId] = useState("");
const [selectedTask, setSelectedTask] = useState(null);
const [prompt, setPrompt] = useState("请在 UniDesk 工作区中完成一个很小的验证任务:读取 package.json 并总结项目名称,不要修改文件。");
const [model, setModel] = useState("gpt-5.4-mini");
const [cwd, setCwd] = useState("/workspace");
const [maxAttempts, setMaxAttempts] = useState(3);
const [steerPrompt, setSteerPrompt] = useState("");
const [autoScroll, setAutoScroll] = useState(true);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [refreshedAt, setRefreshedAt] = useState(null);
const tasks = taskRows(tasksData);
const queue = tasksData?.queue || health?.body?.queue || health?.queue || {};
const counts = queueCounts(queue);
const activeTaskId = queue?.activeTaskId || tasks.find((task: any) => taskIsActive(task))?.id || "";
const runtime = service ? microserviceRuntime(service) : {};
const repository = service ? microserviceRepository(service) : {};
const backend = service ? microserviceBackend(service) : {};
const promptParts = useMemo(() => splitPromptTasks(prompt), [prompt]);
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> {
if (!service) return;
const [healthResult, tasksResult] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/codex-queue/health`),
requestJson(codexApi(apiBaseUrl, "/api/tasks?limit=80")),
]);
setHealth(healthResult);
setTasksData(tasksResult);
const rows = taskRows(tasksResult);
const nextId = preferId && rows.some((task: any) => task.id === preferId)
? preferId
: (tasksResult?.queue?.activeTaskId || rows.find((task: any) => taskIsActive(task))?.id || rows[0]?.id || "");
setSelectedId(nextId);
if (nextId) {
const detail = await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(nextId)}`));
setSelectedTask(detail?.task || null);
} else {
setSelectedTask(null);
}
setRefreshedAt(new Date());
}
async function guarded(action: () => Promise<void>, message: string): Promise<void> {
setBusy(true);
setError("");
try {
await action();
} catch (err) {
setError(errorMessage(err, message));
} finally {
setBusy(false);
}
}
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 });
const firstId = result?.tasks?.[0]?.id || "";
await load(firstId);
}, "Codex 任务入队失败");
}
async function steer(event: any): Promise<void> {
event.preventDefault();
if (!selectedTask?.id) return;
await guarded(async () => {
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/steer`), { method: "POST", body: { prompt: steerPrompt } });
setSteerPrompt("");
await load(selectedTask.id);
}, "追加 prompt 失败");
}
async function interrupt(): Promise<void> {
if (!selectedTask?.id) return;
await guarded(async () => {
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/interrupt`), { method: "POST", body: {} });
await load(selectedTask.id);
}, "打断 Codex session 失败");
}
async function retry(): Promise<void> {
if (!selectedTask?.id) return;
await guarded(async () => {
await requestJson(codexApi(apiBaseUrl, `/api/tasks/${encodeURIComponent(selectedTask.id)}/retry`), { method: "POST", body: {} });
await load(selectedTask.id);
}, "重新入队失败");
}
useEffect(() => {
void guarded(() => load(), "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 轮询失败")));
}, 1500);
return () => window.clearInterval(timer);
}, [service?.id, selectedId]);
if (!service) return h(EmptyState, { title: "Codex Queue 未登记", text: "请在 config.json 的 microservices 中登记 id=codex-queue" });
return h("div", { className: "codex-queue-page", "data-testid": "codex-queue-page" },
h(Panel, {
title: "Codex Queue 控制台",
eyebrow: "App-Server Task Deck",
actions: h("div", { className: "panel-actions" },
h("button", { type: "button", className: "ghost-btn", onClick: () => void guarded(() => load(selectedId), "刷新失败"), disabled: busy, "data-testid": "codex-refresh-button" }, busy ? "同步中" : "刷新"),
h(RawButton, { title: "Codex Queue Microservice", data: service, onOpen: onRaw, testId: "raw-codex-queue-service" }),
),
},
h("div", { className: "codex-queue-hero" },
h("div", null,
h("div", { className: "node-version-line" },
h(StatusBadge, { status: runtime.providerStatus === "online" ? "online" : "warn" }, runtime.providerStatus || "unknown"),
h("span", null, service.providerId),
h("span", null, backend.public ? "公网暴露" : "仅 UniDesk frontend 代理访问"),
h("span", null, queue?.judgeConfigured ? `MiniMax ${queue?.minimaxModel || "M2.7"}` : "Fallback judge"),
),
h("p", { className: "muted paragraph" }, service.description),
),
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("div", { className: "microservice-ref-card" },
h("span", null, "Backend"),
h("strong", null, `${backend.nodeBindHost || "--"}:${backend.nodePort || "--"}`),
h("code", null, repository.containerName || "codex-queue-backend"),
),
),
error ? h("div", { className: "form-error wide" }, error) : null,
),
h("div", { className: "codex-queue-metrics" },
h(MetricCard, { label: "排队", value: countValue(counts, "queued"), hint: "waiting turns" }),
h(MetricCard, { label: "运行", value: countValue(counts, "running"), hint: activeTaskId ? `active ${String(activeTaskId).slice(0, 16)}` : "idle", tone: activeTaskId ? "warn" : "ok" }),
h(MetricCard, { label: "成功", value: countValue(counts, "succeeded"), hint: "completed tasks", tone: "ok" }),
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-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("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("input", { type: "number", min: 1, max: 10, value: maxAttempts, onChange: (event: any) => setMaxAttempts(Number(event.target.value)) })),
),
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("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: "运行控制", eyebrow: selectedCanSteer ? "Active turn steer" : "Steer when running" },
h("form", { className: "codex-steer-form", onSubmit: steer },
h("label", null, "追加 prompt",
h("textarea", { value: steerPrompt, rows: 4, onChange: (event: any) => setSteerPrompt(event.target.value), placeholder: "给正在运行的 Codex session 推入新的指令或纠偏。", disabled: !selectedCanSteer }),
),
h("button", { type: "submit", className: "primary-btn", disabled: !selectedCanSteer || busy || steerPrompt.trim().length === 0, "data-testid": "codex-steer-button" }, "推入运行中 session"),
),
),
h(Panel, { title: "完成判定", eyebrow: selectedTask?.lastJudge ? selectedTask.lastJudge.source : "judge" },
selectedTask?.lastJudge ? h("div", { className: "codex-judge-card" },
h(StatusBadge, { status: selectedTask.lastJudge.decision }, selectedTask.lastJudge.decision),
h("strong", null, `${Math.round(Number(selectedTask.lastJudge.confidence || 0) * 100)}% confidence`),
h("p", null, selectedTask.lastJudge.reason || "--"),
selectedTask.lastJudge.continuePrompt ? h("code", null, shortText(selectedTask.lastJudge.continuePrompt, 220)) : null,
) : h(EmptyState, { title: "尚未判定", text: "Codex turn 结束后会由 MiniMax M2.7 或 fallback judge 判定 complete/retry/failretry 会在已有 thread 追加继续执行 prompt。" }),
),
),
h(Panel, { title: "Attempts", eyebrow: "terminal vs interruption" }, h(AttemptTable, { task: selectedTask })),
),
),
);
}
@@ -64,6 +64,7 @@ export const MODULES: UniDeskModuleDefinition[] = [
{ id: "findjob", label: "FindJob" },
{ id: "pipeline", label: "Pipeline" },
{ id: "met-nonlinear", label: "MET Nonlinear" },
{ id: "codex-queue", label: "Codex Queue" },
] },
{ id: "config", label: "系统配置", code: "CFG", tabs: [
{ id: "topology", label: "连接拓扑" },
+633 -52
View File
@@ -30,6 +30,7 @@ const pipelineSnapshotRunLimit = 10;
const pipelineGanttTimeAxisWidth = 96;
const pipelineGanttNodeColumnWidth = 72;
const pipelineGanttHeaderHeight = 64;
const pipelineGanttArrowTipInsetPx = 12;
function pipelinePercent(value: any, fallback: number): number {
const number = Number.parseFloat(String(value || ""));
@@ -250,6 +251,11 @@ function terminalStatus(status: any): boolean {
return ["succeeded", "failed", "skipped", "cancelled", "canceled", "completed"].includes(String(status || "").toLowerCase());
}
function runningStatus(status: any): boolean {
const value = statusValue(status).toLowerCase();
return ["running", "active", "in-progress", "in_progress"].includes(value);
}
function statusCounts(items: any[], key = "status"): AnyRecord {
return items.reduce((counts: AnyRecord, item: any) => {
const status = String(item?.[key] || "unknown").toLowerCase();
@@ -877,6 +883,8 @@ function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) {
: [];
const roleTone = stepRoleTone(step?.role);
const statusTone = stepStatusTone(step);
const stepStartMs = timeMs(step?.createdAt) ?? timeMs(step?.completedAt);
const stepEndMs = timeMs(step?.completedAt) ?? stepStartMs;
const toolNames = uniqueStrings([...asArray(step?.tools), ...tools.map((part: any) => part?.tool)]).slice(0, 5);
const facts = [
step?.finish ? `finish ${step.finish}` : "",
@@ -887,7 +895,13 @@ function PipelineOpenCodeStep({ step, matched = false }: AnyRecord) {
].filter(Boolean);
const messageRows = pipelineMessageCardRows(step, textParts, reasoningParts);
return h("details", { className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`, open: matched ? true : undefined, "data-testid": "pipeline-opencode-step" },
return h("details", {
className: `pipeline-opencode-step ${roleTone} ${statusTone} ${matched ? "matched" : ""}`,
open: matched ? true : undefined,
"data-testid": "pipeline-opencode-step",
"data-step-start-ms": stepStartMs !== null ? String(stepStartMs) : "",
"data-step-end-ms": stepEndMs !== null ? String(stepEndMs) : "",
},
h("summary", { className: "pipeline-opencode-step-summary", "data-testid": "pipeline-opencode-step-summary" },
h(PipelineStepTimeSummary, { step, role: roleTone, matched }),
h(PipelineStepMessageCard, { rows: messageRows, compact: true, role: roleTone, matched }),
@@ -962,7 +976,6 @@ function controlActionValue(record: any): string {
function controlActionLabel(record: any): string {
switch (controlActionValue(record)) {
case "audit-request": return "待审核";
case "guide": return "引导";
case "modify": return "修改";
case "approve": return "审核通过";
@@ -978,9 +991,9 @@ function eventLabel(record: any): string {
case "append-prompt-delivered": return "追加 prompt";
case "append-prompt-queued": return "追加 prompt 已排队";
case "monitor-prompt-delivered": return "Monitor prompt";
case "monitor-audit-requested": return "待审核";
case "monitor-audit-approved": return "审核通过";
case "monitor-audit-intervened": return "审核被打断";
case "node-long-running-observation": return "长任务观察";
case "node-finished": return "节点完成";
case "oa-policy-downstream-evaluated": return "OA 下游策略";
case "control-command-queued": return `${controlActionLabel(record)} 已发起`;
case "control-command-applied": return `${controlActionLabel(record)} 已生效`;
case "control-command-ignored": return `${controlActionLabel(record)} 已忽略`;
@@ -1162,7 +1175,7 @@ function PipelineProcedureAttemptList({ procedure, matchedStepKey = "", matchedA
{ label: "updated", value: fmtDate(messages.updatedAt) },
{ label: "sessions", value: sessionIds.join(", ") || "--" },
] }),
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-webui 已重建并重新抓取。") :
steps.length === 0 ? h("p", { className: "muted paragraph" }, "当前 attempt 尚未返回 OpenCode step 摘要;请确认 D601 pipeline-control 已重建并重新抓取。") :
h("section", { className: "pipeline-opencode-timeline", "data-testid": "pipeline-step-timeline" },
h("div", { className: "pipeline-opencode-timeline-head" },
h("div", null,
@@ -1472,6 +1485,116 @@ function pipelineStatusCounts(runs: any[]): AnyRecord {
}, {});
}
function pipelineRunScorers(run: any): AnyRecord[] {
if (Array.isArray(run?.scorers)) return run.scorers.filter(isRecord);
if (Array.isArray(run?.summary?.scorers)) return run.summary.scorers.filter(isRecord);
if (Array.isArray(run?.artifact?.summary?.scorers)) return run.artifact.summary.scorers.filter(isRecord);
return [];
}
function pipelineDetailedRun(details: any): AnyRecord | null {
if (isRecord(details?.run)) return details.run;
if (isRecord(details?.runSummary)) return details.runSummary;
return null;
}
function mergePipelineRunSummary(base: any, detail: any): AnyRecord | null {
if (!isRecord(base) && !isRecord(detail)) return null;
if (!isRecord(base)) return detail;
if (!isRecord(detail)) return base;
return {
...base,
...detail,
request: isRecord(base.request) || isRecord(detail.request) ? { ...(isRecord(base.request) ? base.request : {}), ...(isRecord(detail.request) ? detail.request : {}) } : detail.request ?? base.request,
artifact: isRecord(base.artifact) || isRecord(detail.artifact) ? { ...(isRecord(base.artifact) ? base.artifact : {}), ...(isRecord(detail.artifact) ? detail.artifact : {}) } : detail.artifact ?? base.artifact,
summary: isRecord(base.summary) || isRecord(detail.summary) ? { ...(isRecord(base.summary) ? base.summary : {}), ...(isRecord(detail.summary) ? detail.summary : {}) } : detail.summary ?? base.summary,
};
}
function pipelineScoreSummary(run: any): AnyRecord {
const scorers = pipelineRunScorers(run);
const primary = scorers.find((scorer) => isRecord(scorer?.score)) || scorers[0] || null;
const score = isRecord(primary?.score) ? primary.score : {};
const passed = Number(score.passed);
const total = Number(score.total);
const ratio = Number(score.ratio);
const computedRatio = Number.isFinite(ratio)
? ratio
: Number.isFinite(passed) && Number.isFinite(total) && total > 0 ? passed / total : null;
const percent = computedRatio === null ? null : Math.round(Math.max(0, Math.min(100, computedRatio <= 1 ? computedRatio * 100 : computedRatio)));
const text = String(score.text || (Number.isFinite(passed) && Number.isFinite(total) ? `${passed}/${total}` : ""));
return {
scorer: primary,
scorers,
score,
passed: Number.isFinite(passed) ? passed : null,
total: Number.isFinite(total) ? total : null,
percent,
text,
};
}
function pipelineScoreText(run: any): string {
const summary = pipelineScoreSummary(run);
return summary.text || (summary.scorers.length > 0 ? String(summary.scorer?.status || "pending") : "--");
}
function pipelineScoreTone(run: any): string {
const summary = pipelineScoreSummary(run);
if (summary.total > 0 && summary.passed === summary.total) return "succeeded";
if (summary.total > 0 && summary.passed > 0) return "running";
if (summary.scorers.length > 0) return "failed";
return "pending";
}
function pipelineScorerItems(scorer: any): AnyRecord[] {
return Array.isArray(scorer?.items) ? scorer.items.filter(isRecord) : [];
}
function PipelineScoreBadge({ run }: AnyRecord) {
const text = pipelineScoreText(run);
return h("span", { className: `pipeline-score-badge ${pipelineScoreTone(run)}` }, `score ${text}`);
}
function PipelineScoreBoard({ run, onRaw }: AnyRecord) {
const summary = pipelineScoreSummary(run);
const scorers = summary.scorers;
if (!run) return h(EmptyState, { title: "暂无评分", text: "选择一个 epoch 后会显示 scorer 结果。" });
if (scorers.length === 0) return h("div", { className: "pipeline-score-empty" },
h("strong", null, "评分器等待中"),
h("span", null, "DAG 完成后,Pipeline control backend 会把 scorer summary 追加到 run artifact,并通过 UniDesk 显示。"),
);
return h("div", { className: "pipeline-score-board", "data-testid": "pipeline-score-board" },
scorers.map((scorer: AnyRecord, index: number) => {
const localSummary = pipelineScoreSummary({ scorers: [scorer] });
const items = pipelineScorerItems(scorer);
const percent = localSummary.percent ?? 0;
return h("article", { key: `${scorer.scorerId || scorer.component || index}`, className: `pipeline-score-card ${pipelineScoreTone({ scorers: [scorer] })}` },
h("div", { className: "pipeline-score-head" },
h("div", null,
h("span", null, scorer.scorerId || scorer.component || "scorer"),
h("strong", null, localSummary.text || scorer.status || "--"),
),
h(StatusBadge, { status: scorer.status || "unknown" }, scorer.status || "unknown"),
),
h("div", { className: "pipeline-score-meter", "aria-label": `score ${percent}%` }, h("span", { style: { width: `${percent}%` } })),
h("div", { className: "pipeline-score-facts" },
h("span", null, `${percent}%`),
h("span", null, scorer.component || "--"),
h("span", null, scorer.applicationCheckoutRef || "--"),
),
items.length > 0 ? h("div", { className: "pipeline-score-items" }, items.map((item: AnyRecord) => h("span", {
key: `${item.id || item.filter}`,
className: `pipeline-score-item ${String(item.status || "").toLowerCase()}`,
title: `${item.filter || "--"} / ran=${item.ran ?? "?"}`,
}, h("b", null, item.id || "--"), h("small", null, item.status || "--")))) : h("p", { className: "muted paragraph" }, "当前 scorer 尚未返回 item 级结果。"),
scorer.error ? h("p", { className: "pipeline-score-error" }, previewText(scorer.error, 360)) : null,
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: `Scorer ${scorer.scorerId || index}`, data: scorer, onOpen: onRaw, testId: "raw-pipeline-score" })),
);
}),
);
}
function pipelineComponentClassCounts(components: any[]): Array<{ name: string; count: number }> {
const counts = components.reduce((memo: AnyRecord, component: any) => {
const name = String(component?.componentClass || "unknown");
@@ -1496,7 +1619,23 @@ function pipelineNodeIsMonitor(node: any, component?: any): boolean {
if (String(node?.kind || "").toLowerCase() !== "procedure") return false;
const monitorInputs = pipelineMonitorInputs(node);
if (node?.instanceInputs?.monitorMode === true || monitorInputs.enabled === true) return true;
return String(component?.id || component?.config?.id || "").toLowerCase().includes("monitor");
const componentRef = pipelineComponentRef(node?.componentRef);
return String(component?.id || component?.config?.id || componentRef || "").toLowerCase().includes("monitor");
}
function pipelineMonitorNodeIds(pipelineNodes: AnyRecord[]): string[] {
return pipelineNodes
.filter((node: any) => pipelineNodeIsMonitor(node))
.map((node: any) => String(node?.id || ""))
.filter(Boolean);
}
function pipelineOrderWithLeadingMonitors(nodeIds: string[], monitorNodeIds: string[]): string[] {
if (monitorNodeIds.length === 0) return nodeIds;
const monitorSet = new Set(monitorNodeIds);
const leading = monitorNodeIds.filter((nodeId) => nodeIds.includes(nodeId));
if (leading.length === 0) return nodeIds;
return [...leading, ...nodeIds.filter((nodeId) => !monitorSet.has(nodeId))];
}
function pipelineColumnsWithLeadingMonitors(columns: string[][], monitorNodeIds: string[]): string[][] {
@@ -1582,7 +1721,7 @@ function pipelineGraphNodeOrder(pipeline: any, pipelineNodes: AnyRecord[], pipel
ordered.push(nodeId);
seen.add(nodeId);
}
return ordered;
return pipelineOrderWithLeadingMonitors(ordered, pipelineMonitorNodeIds(pipelineNodes));
}
function pipelineFlowEdgeKey(edge: AnyRecord): string {
@@ -1843,6 +1982,10 @@ function exportMarkerId(color: string): string {
return `arrow-${color.replace(/[^a-zA-Z0-9_-]+/g, "")}`;
}
function safeExportFileTitle(title: string, fallback = "pipeline"): string {
return String(title || fallback).replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || fallback;
}
function targetPortPosition(node: Node, handle: string): { x: number; y: number; position: Position } {
const x = node.position.x;
const y = node.position.y;
@@ -1911,6 +2054,171 @@ function pipelineGraphSvg(flow: { nodes: Node[]; edges: Edge[] }, title: string)
return { svg, width, height };
}
function pipelineGanttExportStatusColor(status: any): string {
const value = String(status || "").toLowerCase();
if (value === "succeeded" || value === "completed") return "#4eb7a8";
if (value === "failed") return "#cf6a54";
if (runningStatus(value)) return "#69aee8";
return "#d7a13a";
}
function pipelineGanttExportMarkerColor(marker: AnyRecord): string {
const kind = String(marker?.kind || "");
const tone = String(marker?.tone || marker?.status || "").toLowerCase();
if (kind === "prompt" && tone === "initial") return "#d7a13a";
if (kind === "prompt" && tone === "monitor") return "#69aee8";
if (kind === "prompt") return "#4eb7a8";
if (tone === "modify") return "#e0b95a";
if (tone === "approve" || tone === "guide" || tone === "monitor") return "#4eb7a8";
if (tone === "restart" || tone === "redo") return "#d7a13a";
if (tone === "ignored") return "#81939f";
if (tone === "webui") return "#69aee8";
if (tone === "cli") return "#d7a13a";
return "#a7bac5";
}
function pipelineGanttExportArrowColor(arrow: AnyRecord): string {
const sourceKind = String(arrow?.sourceKind || "").toLowerCase();
const action = String(arrow?.action || "").toLowerCase();
const status = String(arrow?.status || "").toLowerCase();
if (action === "observe" || status === "observation" || sourceKind === "monitor") return "#4eb7a8";
if (sourceKind === "webui") return "#69aee8";
if (sourceKind === "cli") return "#d7a13a";
if (status.includes("ignored")) return "#81939f";
return "#8aa0ad";
}
function pipelineGanttExportMarkerSvg(marker: AnyRecord, x: number, y: number): string {
const color = pipelineGanttExportMarkerColor(marker);
const kind = String(marker?.kind || "");
if (kind === "control-source") {
return `<rect x="${x - 5.5}" y="${y - 5.5}" width="11" height="11" rx="2" transform="rotate(45 ${x} ${y})" fill="${color}" stroke="rgba(255,255,255,0.26)" stroke-width="1"/>`;
}
if (kind === "control-target") {
const fill = String(marker?.tone || "").toLowerCase() === "approve" ? "rgba(78,183,168,0.22)" : "#081118";
return `<circle cx="${x}" cy="${y}" r="5.5" fill="${fill}" stroke="${color}" stroke-width="2"/>`;
}
const radius = kind === "prompt" ? 4.5 : 5.5;
return `<circle cx="${x}" cy="${y}" r="${radius}" fill="${color}" stroke="rgba(255,255,255,0.24)" stroke-width="1"/>`;
}
function pipelineGanttSvg(input: AnyRecord): { svg: string; width: number; height: number } {
const nodeIds = asArray(input.visibleNodeIds).map((nodeId) => String(nodeId || "")).filter(Boolean);
const intervals = asArray(input.intervals).filter(isRecord);
const markers = asArray(input.markers).filter(isRecord);
const arrows = asArray(input.arrows).filter(isRecord);
const ticks = asArray(input.ticks).filter(isRecord);
const bounds = isRecord(input.bounds) ? input.bounds : {};
const backendLayout = isRecord(input.backendLayout) ? input.backendLayout : null;
const chartHeight = Math.max(240, Math.round(Number(input.chartHeight || 360)));
const exportColumnWidth = Math.max(pipelineGanttNodeColumnWidth, 108);
const timeAxisWidth = 128;
const padding = 24;
const titleHeight = 58;
const headerHeight = 56;
const boardWidth = timeAxisWidth + Math.max(1, nodeIds.length) * exportColumnWidth;
const width = Math.max(760, boardWidth + padding * 2);
const height = titleHeight + headerHeight + chartHeight + padding;
const boardX = padding;
const boardY = titleHeight;
const chartY = boardY + headerHeight;
const nodeX = (index: number) => boardX + timeAxisWidth + index * exportColumnWidth;
const nodeCenterX = (index: number) => nodeX(index) + exportColumnWidth / 2;
const meta = asArray(input.meta).map((item) => String(item || "")).filter(Boolean).slice(0, 4).join(" · ");
const markerById = new Map(markers.map((marker) => [String(marker.id || ""), marker]));
const markerColors = Array.from(new Set(["#4eb7a8", "#69aee8", "#d7a13a", "#cf6a54", "#8aa0ad", ...arrows.map(pipelineGanttExportArrowColor)]));
const defs = markerColors.map((color) =>
`<marker id="${exportMarkerId(color)}" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="${color}"/></marker>`,
).join("");
const tickSvg = ticks.map((tick) => {
const y = chartY + pipelineGanttTickY(tick, bounds, chartHeight, backendLayout);
return `<g>
<line x1="${boardX}" y1="${y}" x2="${boardX + boardWidth}" y2="${y}" stroke="rgba(215,161,58,0.18)" stroke-width="1"/>
<text x="${boardX + 8}" y="${y - 4}" fill="#dce6ec" font-size="10" font-family="monospace">${escapeSvg(fmtDate(tick.ms))}</text>
<text x="${boardX + 8}" y="${y + 10}" fill="#81939f" font-size="9" font-family="monospace">+${escapeSvg(fmtDurationMs(Number(tick.offsetMs ?? Number(tick.ms) - Number(bounds.startMs))))}</text>
</g>`;
}).join("\n");
const headerSvg = [
`<rect x="${boardX}" y="${boardY}" width="${timeAxisWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>`,
`<text x="${boardX + 10}" y="${boardY + 20}" fill="#d7a13a" font-size="10" font-family="monospace" letter-spacing="1.2">TIME</text>`,
...nodeIds.map((nodeId, index) => {
const x = nodeX(index);
const label = nodeId.length > 18 ? `${nodeId.slice(0, 16)}` : nodeId;
return `<g>
<rect x="${x}" y="${boardY}" width="${exportColumnWidth}" height="${headerHeight}" fill="#101d25" stroke="rgba(255,255,255,0.08)"/>
<text x="${x + exportColumnWidth / 2}" y="${boardY + 21}" fill="#dce6ec" font-size="10" font-family="monospace" text-anchor="middle">${escapeSvg(label)}</text>
<text x="${x + exportColumnWidth / 2}" y="${boardY + 39}" fill="#81939f" font-size="9" font-family="monospace" text-anchor="middle">node ${index + 1}</text>
</g>`;
}),
].join("\n");
const columnSvg = nodeIds.map((_nodeId, index) => {
const x = nodeX(index);
return `<rect x="${x}" y="${chartY}" width="${exportColumnWidth}" height="${chartHeight}" fill="${index % 2 === 0 ? "rgba(255,255,255,0.018)" : "rgba(255,255,255,0.008)"}" stroke="rgba(255,255,255,0.045)"/>`;
}).join("\n");
const intervalSvg = intervals.map((interval) => {
const index = nodeIds.indexOf(String(interval.nodeId || ""));
if (index < 0) return "";
const top = chartY + pipelineGanttIntervalTop(interval, bounds, chartHeight, backendLayout);
const barHeight = Math.max(2, pipelineGanttIntervalHeight(interval, bounds, chartHeight, backendLayout));
const color = pipelineGanttExportStatusColor(interval.status);
const x = nodeCenterX(index) - 3.5;
const liveOverlay = interval.live ? `<rect x="${x - 1}" y="${top}" width="9" height="${barHeight}" rx="5" fill="none" stroke="rgba(255,255,255,0.54)" stroke-width="1" stroke-dasharray="4 5"/>` : "";
const label = barHeight >= 28
? `<text x="${x + 12}" y="${top + 12}" fill="${color}" font-size="9" font-family="monospace">${escapeSvg(String(interval.status || "working"))}</text>
<text x="${x + 12}" y="${top + 24}" fill="#81939f" font-size="8" font-family="monospace">${escapeSvg(fmtDurationMs(interval.durationMs))}</text>`
: "";
return `<g>
<rect x="${x}" y="${top}" width="7" height="${barHeight}" rx="4" fill="${color}" stroke="rgba(0,0,0,0.42)" stroke-width="1"/>
${liveOverlay}
${label}
</g>`;
}).join("\n");
const markerSvg = markers.map((marker) => {
const index = nodeIds.indexOf(String(marker.nodeId || ""));
if (index < 0) return "";
const y = chartY + pipelineGanttMarkerY(marker, bounds, chartHeight, backendLayout);
return pipelineGanttExportMarkerSvg(marker, nodeCenterX(index), y);
}).join("\n");
const arrowSvg = arrows.map((arrow) => {
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
if (!targetMarker) return "";
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
const targetNodeId = String(targetMarker.nodeId || arrow.targetNodeId || "");
const sourceIndex = nodeIds.indexOf(sourceNodeId);
const targetIndex = nodeIds.indexOf(targetNodeId);
if (sourceIndex < 0 || targetIndex < 0) return "";
const sourceX = nodeCenterX(sourceIndex) - boardX - timeAxisWidth;
const targetX = nodeCenterX(targetIndex) - boardX - timeAxisWidth;
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
const color = pipelineGanttExportArrowColor(arrow);
const dash = String(arrow.action || "").toLowerCase() === "observe" ? "3 4" : "6 5";
const path = escapeSvg(pipelineControlArrowPath(sourceX, sourceY, targetX, targetY));
return `<path d="${path}" fill="none" stroke="rgba(8,17,24,0.92)" stroke-width="5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="${path}" fill="none" stroke="${color}" stroke-width="2.45" stroke-dasharray="${dash}" stroke-linecap="round" stroke-linejoin="round" opacity="0.96" marker-end="url(#${exportMarkerId(color)})"/>`;
}).join("\n");
const emptySvg = nodeIds.length === 0 ? `<text x="${boardX + timeAxisWidth + 24}" y="${chartY + 42}" fill="#81939f" font-size="12" font-family="monospace">No visible Gantt nodes</text>` : "";
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<defs>${defs}<pattern id="ganttGrid" width="32" height="32" patternUnits="userSpaceOnUse"><path d="M 32 0 L 0 0 0 32" fill="none" stroke="#18303a" stroke-width="0.55"/></pattern></defs>
<rect width="100%" height="100%" fill="#081118"/>
<rect width="100%" height="100%" fill="url(#ganttGrid)" opacity="0.55"/>
<rect x="${boardX}" y="${chartY}" width="${boardWidth}" height="${chartHeight}" fill="rgba(255,255,255,0.012)" stroke="rgba(78,183,168,0.24)"/>
<text x="${padding}" y="26" fill="#d7a13a" font-size="13" font-family="monospace" font-weight="700" letter-spacing="1.4">${escapeSvg(input.title || "Pipeline Epoch Gantt")}</text>
<text x="${padding}" y="45" fill="#81939f" font-size="10" font-family="monospace">${escapeSvg(meta)}</text>
${headerSvg}
<rect x="${boardX}" y="${chartY}" width="${timeAxisWidth}" height="${chartHeight}" fill="rgba(215,161,58,0.055)" stroke="rgba(215,161,58,0.2)"/>
${columnSvg}
${tickSvg}
${intervalSvg}
<g transform="translate(${boardX + timeAxisWidth} ${chartY})">${arrowSvg}</g>
${markerSvg}
${emptySvg}
</svg>`;
return { svg, width, height };
}
function downloadBlob(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
@@ -1921,7 +2229,7 @@ function downloadBlob(blob: Blob, filename: string): void {
}
async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title: string): Promise<void> {
const safeTitle = String(title || "pipeline").replace(/[^a-zA-Z0-9_-]+/g, "-").replace(/^-|-$/g, "") || "pipeline";
const safeTitle = safeExportFileTitle(title, "pipeline");
const { svg, width, height } = pipelineGraphSvg(flow, title);
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
@@ -1948,6 +2256,34 @@ async function exportPipelineGraph(flow: { nodes: Node[]; edges: Edge[] }, title
}
}
async function exportPipelineGantt(input: AnyRecord): Promise<void> {
const safeTitle = safeExportFileTitle(String(input?.title || "pipeline-gantt"), "pipeline-gantt");
const { svg, width, height } = pipelineGanttSvg(input);
const svgBlob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
const url = URL.createObjectURL(svgBlob);
try {
const image = new Image();
await new Promise<void>((resolve, reject) => {
image.onload = () => resolve();
image.onerror = () => reject(new Error("gantt svg image load failed"));
image.src = url;
});
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext("2d");
if (!ctx) throw new Error("canvas unavailable");
ctx.drawImage(image, 0, 0);
const pngBlob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
if (!pngBlob) throw new Error("gantt png export failed");
downloadBlob(pngBlob, `${safeTitle}.png`);
} catch {
downloadBlob(svgBlob, `${safeTitle}.svg`);
} finally {
URL.revokeObjectURL(url);
}
}
async function exportPipelineGraphs(items: Array<{ flow: { nodes: Node[]; edges: Edge[] }; title: string }>): Promise<void> {
for (const item of items) {
if (item.flow.nodes.length === 0) continue;
@@ -2021,7 +2357,7 @@ function procedureEndIso(procedure: any, run: any): string {
);
}
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[] {
function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[], nowMs = Date.now()): AnyRecord[] {
const runId = String(run?.runId || "");
const knownNodeIds = new Set(pipelineNodes.map((node: any) => String(node?.id || "")).filter(Boolean));
return asArray(run?.procedureRuns).flatMap((procedure: any) => {
@@ -2032,7 +2368,7 @@ function pipelineRunIntervals(run: any, pipelineNodes: AnyRecord[]): AnyRecord[]
const startMs = timeMs(startIso);
if (startMs === null) return [];
const explicitEndIso = procedureEndIso(procedure, run);
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : Date.now());
const endMs = timeMs(explicitEndIso) ?? (terminalStatus(status) ? (timeMs(procedure?.updatedAt) ?? startMs + 1000) : nowMs);
const safeEndMs = Math.max(startMs + 1000, endMs);
return [{
nodeId,
@@ -2089,6 +2425,11 @@ function pipelineGanttScaleConfig(value: number): AnyRecord {
return { value: clampPipelineGanttScale(normalized * 100), pxPerMinute, label };
}
function pipelineDisplayPxPerMinute(value: any): number {
const rounded = Math.round(Number(value));
return Math.abs(rounded - pipelineDefaultGanttPxPerMinute) <= 1 ? pipelineDefaultGanttPxPerMinute : rounded;
}
function pipelineGanttHeight(bounds: AnyRecord, scaleValue = pipelineDefaultGanttScale): number {
const minutes = Math.max(1, Number(bounds.durationMs || 0) / 60_000);
const scale = pipelineGanttScaleConfig(scaleValue);
@@ -2123,6 +2464,69 @@ function pipelineGanttNumber(value: any): number | null {
return Number.isFinite(number) ? number : null;
}
function pipelineGanttIntervalIsRunning(interval: AnyRecord): boolean {
return runningStatus(interval?.status) && !terminalStatus(interval?.status);
}
function pipelineGanttMsToY(ms: number, layout: AnyRecord | null): number | null {
if (!layout) return null;
const startMs = pipelineGanttNumber(layout?.startMs);
const endMs = pipelineGanttNumber(layout?.endMs);
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
if (startMs === null || endMs === null || chartHeight === null) return null;
const durationMs = Math.max(1, endMs - startMs);
return Math.max(0, ((ms - startMs) / durationMs) * chartHeight);
}
function pipelineGanttLiveEndMs(interval: AnyRecord, nowMs: number): number {
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
const currentEndMs = pipelineGanttNumber(interval?.endMs) ?? startMs + 1000;
if (!pipelineGanttIntervalIsRunning(interval)) return Math.max(startMs + 1000, currentEndMs);
return Math.max(startMs + 1000, currentEndMs, nowMs);
}
function pipelineGanttLiveBackendLayout(layout: AnyRecord | null, intervals: AnyRecord[], nowMs: number): AnyRecord | null {
if (!layout) return null;
const startMs = pipelineGanttNumber(layout?.startMs);
const endMs = pipelineGanttNumber(layout?.endMs);
const chartHeight = pipelineGanttNumber(layout?.chartHeight);
if (startMs === null || endMs === null || chartHeight === null) return layout;
const liveEndMs = intervals.reduce((maxMs, interval) => Math.max(maxMs, pipelineGanttLiveEndMs(interval, nowMs)), endMs);
if (liveEndMs <= endMs) return layout;
const pxPerMs = chartHeight / Math.max(1, endMs - startMs);
return {
...layout,
endMs: liveEndMs,
durationMs: Math.max(1, liveEndMs - startMs),
chartHeight: Math.max(chartHeight, Math.round((liveEndMs - startMs) * pxPerMs)),
liveExtended: true,
};
}
function pipelineGanttNormalizeLiveInterval(interval: AnyRecord, layout: AnyRecord | null, nowMs: number): AnyRecord {
if (!pipelineGanttIntervalIsRunning(interval)) return interval;
const startMs = pipelineGanttNumber(interval?.rawStartMs ?? interval?.startMs) ?? pipelineGanttNumber(interval?.startMs) ?? nowMs;
const endMs = pipelineGanttLiveEndMs(interval, nowMs);
const y1 = pipelineGanttMsToY(startMs, layout);
const y2 = pipelineGanttMsToY(endMs, layout);
const safeY1 = pipelineGanttNumber(y1 ?? interval?.y1 ?? interval?.startY) ?? 0;
const safeY2 = pipelineGanttNumber(y2 ?? interval?.y2 ?? interval?.endY) ?? safeY1 + 10;
const height = Math.max(24, safeY2 - safeY1);
return {
...interval,
live: true,
startMs,
endMs,
durationMs: Math.max(1000, endMs - startMs),
finishedAt: isoFromMs(endMs),
y1: safeY1,
y2: safeY2,
startY: safeY1,
endY: safeY2,
height,
};
}
function pipelineGanttFallbackY(ms: number, bounds: AnyRecord, chartHeight: number): number {
return (markerPercent(ms, bounds) / 100) * chartHeight;
}
@@ -2205,30 +2609,72 @@ function pipelineGanttNormalizeBackendMarker(marker: AnyRecord): AnyRecord {
};
}
function pipelineObservedPromptSourceNodeId(marker: AnyRecord): string {
const promptEvent = String(marker?.promptEvent || marker?.raw?.promptEvent || marker?.event || "").toLowerCase();
if (!["node-long-running-observation", "node-finished"].includes(promptEvent)) return "";
const sourceNodeId = String(marker?.sourceNodeId || marker?.raw?.sourceNodeId || marker?.raw?.detail?.nodeId || "");
const monitorNodeId = String(marker?.nodeId || marker?.targetNodeId || "");
return sourceNodeId && sourceNodeId !== monitorNodeId ? sourceNodeId : "";
}
function pipelineGanttAddObservationArrows(markers: AnyRecord[], arrows: AnyRecord[]): AnyRecord {
const arrowKeys = new Set(arrows.map((arrow) => [
String(arrow.sourceNodeId || ""),
String(arrow.targetNodeId || ""),
String(arrow.targetMarkerId || ""),
String(arrow.action || ""),
].join(":")));
const nextArrows = [...arrows];
for (const marker of markers) {
const observedNodeId = pipelineObservedPromptSourceNodeId(marker);
const monitorNodeId = String(marker?.nodeId || "");
const targetMarkerId = String(marker?.id || "");
if (!observedNodeId || !monitorNodeId || !targetMarkerId) continue;
const arrowKey = [observedNodeId, monitorNodeId, targetMarkerId, "observe"].join(":");
if (arrowKeys.has(arrowKey)) continue;
arrowKeys.add(arrowKey);
nextArrows.push({
id: `observation-arrow:${targetMarkerId}:${observedNodeId}:${monitorNodeId}`,
commandId: String(marker?.commandId || marker?.eventId || targetMarkerId),
sourceNodeId: observedNodeId,
targetNodeId: monitorNodeId,
sourceMarkerId: "",
targetMarkerId,
sourceKind: "monitor",
action: "observe",
status: "observation",
});
}
return { markers, arrows: nextArrows };
}
function pipelineBackendGanttSignals(details: any): AnyRecord {
const gantt = isRecord(details?.gantt) ? details.gantt : {};
return {
markers: asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker),
arrows: asArray(gantt.arrows).filter(isRecord),
};
const rawMarkers = asArray(gantt.markers).filter(isRecord).map(pipelineGanttNormalizeBackendMarker);
const arrows = asArray(gantt.arrows).filter(isRecord);
return pipelineGanttAddObservationArrows(rawMarkers, arrows);
}
function pipelinePromptMarkerTone(record: any, fallbackKind = ""): string {
const kind = eventKind(record) || fallbackKind;
const promptEvent = String(record?.promptEvent || "");
if (kind === "initial-prompt-delivered") return "initial";
if (promptEvent === "node-audit-required" || promptEvent.startsWith("monitor-")) return "monitor";
if (promptEvent === "node-finished" || promptEvent === "node-long-running-observation" || promptEvent.startsWith("monitor-")) return "monitor";
if (kind === "monitor-prompt-delivered" || String(record?.sourceKind || "").toLowerCase() === "monitor" || fallbackKind === "monitor-prompt-queued") return "monitor";
return "append";
}
function pipelineRecordTags(record: any): string[] {
return asArray(record?.tags || record?.raw?.tags).map((tag) => String(tag || "")).filter(Boolean);
}
function pipelinePromptMarkerLabel(record: any, fallbackKind = ""): string {
const kind = eventKind(record) || fallbackKind;
const promptEvent = String(record?.promptEvent || "");
if (kind === "initial-prompt-delivered") return "初始 prompt";
if (promptEvent === "node-audit-required") return "审核请求";
if (promptEvent === "monitor-interval") return "Monitor interval";
if (promptEvent === "batch-finished") return "批次完成";
if (promptEvent === "node-long-running-observation") return "长任务观察";
if (promptEvent === "node-finished") return pipelineRecordTags(record).includes("monitor.audit") ? "节点完成 / OA 审核" : "节点完成";
if (promptEvent === "monitor-interval") return "旧版轮询";
if (promptEvent === "monitor-start") return "Monitor start";
if (promptEvent === "monitor-stop") return "Monitor stop";
if (kind === "monitor-prompt-delivered" || fallbackKind === "monitor-prompt-queued") return "Monitor prompt";
@@ -2316,10 +2762,14 @@ function controlTargetMarkerPlacement(intervals: AnyRecord[], nodeId: string, ev
}
function pipelineControlArrowPath(sourceX: number, sourceY: number, targetX: number, targetY: number): string {
const deltaX = targetX - sourceX;
const distance = Math.hypot(targetX - sourceX, targetY - sourceY);
const inset = distance > pipelineGanttArrowTipInsetPx ? pipelineGanttArrowTipInsetPx : 0;
const endX = inset > 0 ? targetX - ((targetX - sourceX) / distance) * inset : targetX;
const endY = inset > 0 ? targetY - ((targetY - sourceY) / distance) * inset : targetY;
const deltaX = endX - sourceX;
const bend = Math.max(16, Math.min(42, Math.abs(deltaX) * 0.45 + 12));
const sign = deltaX === 0 ? 1 : Math.sign(deltaX);
return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${targetX - sign * bend},${targetY} ${targetX},${targetY}`;
return `M ${sourceX},${sourceY} C ${sourceX + sign * bend},${sourceY} ${endX - sign * bend},${endY} ${endX},${endY}`;
}
function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
@@ -2517,7 +2967,7 @@ function pipelineRunGanttSignals(details: any, activeRun: any): AnyRecord {
Number(left.ms) - Number(right.ms)
|| String(left.nodeId).localeCompare(String(right.nodeId))
|| String(left.id).localeCompare(String(right.id)));
return { markers, arrows: controlArrows, sourceMarkerByCommand };
return { ...pipelineGanttAddObservationArrows(markers, controlArrows), sourceMarkerByCommand };
}
function PipelineNodeExecutionIndex({ details, selectedNodeId, selectedNodeRuntime, control, onRaw }: AnyRecord) {
@@ -2678,20 +3128,85 @@ function PipelineRunMaterialIndex({ activeRun, onRaw }: AnyRecord) {
);
}
function PipelineOaEventFlowPanel({ diagnostics, onRaw }: AnyRecord) {
const runs = asArray(diagnostics?.runs).filter(isRecord);
const forbiddenResiduals = asArray(diagnostics?.forbiddenResiduals);
const guarantees = isRecord(diagnostics?.guarantees) ? diagnostics.guarantees : {};
const evidenceOk = diagnostics?.hasNeutralNodeFinishedEvidence === true
&& diagnostics?.hasNoAuditPolicyEvidence === true
&& diagnostics?.hasAuditPolicyEvidence === true;
const ok = diagnostics?.ok === true && evidenceOk && forbiddenResiduals.length === 0;
const latestRun = runs[0] || null;
const guaranteeItems = [
{ label: "中性完成事实", ok: guarantees.neutralNodeFinished === true, hint: "node-finished 不携带流程策略" },
{ label: "Config 策略判定", ok: guarantees.auditPolicyFromConfig === true, hint: "OA backend 读取当前 epoch 配置" },
{ label: "控制命令来自 OA", ok: guarantees.runnerConsumesControlCommandsFromOaEvents === true, hint: "runner 只消费 OA control.command" },
{ label: "无独立审核事件", ok: guarantees.noIndependentAuditRequestEvent === true, hint: "审核由 node-finished + policy 派生" },
{ label: "无批次门禁", ok: guarantees.noBatchFinishedControlGate === true, hint: "下游启动由每个 node 完成驱动" },
];
return h("div", { className: "pipeline-oa-panel", "data-testid": "pipeline-oa-event-flow-panel" },
h("div", { className: "metric-grid compact" },
h(MetricCard, { label: "OA Flow", value: ok ? "100%" : "--", hint: String(diagnostics?.mode || "waiting diagnostics"), tone: ok ? "ok" : "warn" }),
h(MetricCard, { label: "禁止残留", value: forbiddenResiduals.length, hint: forbiddenResiduals.length === 0 ? "source scan clean" : "needs cleanup", tone: forbiddenResiduals.length === 0 ? "ok" : "warn" }),
h(MetricCard, { label: "No-audit", value: diagnostics?.hasNoAuditPolicyEvidence ? "OK" : "--", hint: "OA 下游策略证据", tone: diagnostics?.hasNoAuditPolicyEvidence ? "ok" : "warn" }),
h(MetricCard, { label: "Monitor 审核", value: diagnostics?.hasAuditPolicyEvidence ? "OK" : "--", hint: "OA 控制事件闭环", tone: diagnostics?.hasAuditPolicyEvidence ? "ok" : "warn" }),
),
h("div", { className: "pipeline-oa-guarantees" },
guaranteeItems.map((item) => h("article", { key: item.label, className: `pipeline-oa-guarantee ${item.ok ? "ok" : "warn"}` },
h(StatusBadge, { status: item.ok ? "online" : "warn" }, item.ok ? "OK" : "MISS"),
h("div", null, h("strong", null, item.label), h("span", null, item.hint)),
)),
),
h("div", { className: "pipeline-evidence-list compact" },
runs.slice(0, 6).map((run: AnyRecord) => h(EvidenceIndexRow, {
key: run.runId,
title: String(run.runId || "--"),
subtitle: [
Number(run.monitorAuditNodeFinishedCount || 0) > 0 ? "monitor audit" : "",
Number(run.noAuditPolicyCount || 0) > 0 ? "no-audit policy" : "",
].filter(Boolean).join(" / ") || "event evidence",
facts: [
`events ${run.eventCount || 0}`,
`node-finished ${run.nodeFinishedCount || 0}`,
`policy-in-detail ${run.nodeFinishedWithPolicyCount || 0}`,
`queued ${run.controlQueuedCount || 0}`,
`applied ${run.controlAppliedCount || 0}`,
],
data: run,
onRaw,
testId: `raw-pipeline-oa-run-${String(run.runId || "run").replace(/[^a-zA-Z0-9_.-]+/g, "-")}`,
})),
),
latestRun ? h("p", { className: "muted paragraph" }, `最新证据 ${latestRun.runId}: ${latestRun.nodeFinishedCount || 0} 个 node-finished${latestRun.controlAppliedCount || 0} 个控制结果。`) : h(EmptyState, { title: "暂无 OA 事件流证据", text: "等待 Pipeline backend 暴露 diagnostics。" }),
diagnostics ? h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline OA Event Flow Diagnostics", data: diagnostics, onOpen: onRaw, testId: "raw-pipeline-oa-event-flow" })) : null,
);
}
function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes, pipelineEdges, runDetails, nodeDetails, ganttScale = pipelineDefaultGanttScale, onGanttScaleChange, onRunChange, onIntervalSelect, onMarkerSelect, selection, onRaw }: AnyRecord) {
const [autoHideIdle, setAutoHideIdle] = useState(pipelineDefaultGanttAutoHideIdle);
const [visibleRange, setVisibleRange] = useState({ startY: 0, endY: 0, startMs: 0, endMs: 0 });
const [liveNowMs, setLiveNowMs] = useState(Date.now());
const viewportRef = useRef(null);
const activeRunId = String(activeRun?.runId || "");
const timeScale = clampPipelineGanttScale(ganttScale ?? pipelineDefaultGanttScale);
const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes);
const fallbackIntervals = pipelineRunIntervals(activeRun, pipelineNodes, liveNowMs);
const runDetailPayload = String(runDetails?.runId || "") === activeRunId ? runDetails?.details : null;
const backendGantt = isRecord(runDetailPayload?.gantt) ? runDetailPayload.gantt : null;
const backendLayout = pipelineGanttBackendLayout(backendGantt);
const hasBackendLayout = Boolean(backendLayout);
const intervals = hasBackendLayout
const baseBackendLayout = pipelineGanttBackendLayout(backendGantt);
const hasBackendLayout = Boolean(baseBackendLayout);
const rawIntervals = hasBackendLayout
? asArray(backendGantt?.intervals).filter(isRecord).map((interval: AnyRecord) => ({ ...interval, runId: activeRunId }))
: fallbackIntervals;
const hasLiveRunning = rawIntervals.some(pipelineGanttIntervalIsRunning);
useEffect(() => {
if (!activeRunId || !hasLiveRunning) return undefined;
const timer = window.setInterval(() => setLiveNowMs(Date.now()), 1000);
return () => window.clearInterval(timer);
}, [activeRunId, hasLiveRunning]);
const backendLayout = hasBackendLayout ? pipelineGanttLiveBackendLayout(baseBackendLayout, rawIntervals, liveNowMs) : baseBackendLayout;
const intervals = hasBackendLayout
? rawIntervals.map((interval: AnyRecord) => pipelineGanttNormalizeLiveInterval(interval, backendLayout, liveNowMs))
: rawIntervals;
const bounds = hasBackendLayout ? {
startMs: Number(backendLayout?.startMs),
endMs: Number(backendLayout?.endMs),
@@ -2777,14 +3292,37 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
const visibleMarkerIds = new Set(allMarkers.filter((marker: AnyRecord) =>
visibleNodeIds.includes(String(marker.nodeId || "")) && markerIsVisible(marker)).map((marker: AnyRecord) => String(marker.id)));
const markerById = new Map(allMarkers.map((marker: AnyRecord) => [String(marker.id), marker]));
const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) =>
visibleMarkerIds.has(String(arrow.sourceMarkerId || "")) && visibleMarkerIds.has(String(arrow.targetMarkerId || "")));
const visibleArrows = asArray(ganttSignals.arrows).filter((arrow: AnyRecord) => {
const targetVisible = visibleMarkerIds.has(String(arrow.targetMarkerId || ""));
if (!targetVisible) return false;
if (String(arrow.action || "") === "observe") return visibleNodeIds.includes(String(arrow.sourceNodeId || ""));
return visibleMarkerIds.has(String(arrow.sourceMarkerId || ""));
});
const boardMinWidth = pipelineGanttTimeAxisWidth + Math.max(1, visibleNodeIds.length) * pipelineGanttNodeColumnWidth;
const setScaleFromSlider = (event: any) => {
const nextScale = clampPipelineGanttScale(event.target.value);
if (typeof onGanttScaleChange === "function") onGanttScaleChange(nextScale);
window.setTimeout(updateVisibleRange, 0);
};
const exportCurrentGantt = () => exportPipelineGantt({
title: `${activePipeline?.id || "pipeline"}-${activeRunId || "epoch"}-gantt`,
meta: [
`run ${activeRunId || "--"}`,
`${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`,
`duration ${fmtDurationMs(bounds.durationMs)}`,
`${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`,
`${visibleNodeIds.length}/${allNodeIds.length} nodes`,
`${allMarkers.length} markers`,
],
visibleNodeIds,
intervals,
markers: allMarkers.filter((marker: AnyRecord) => visibleNodeIds.includes(String(marker.nodeId || ""))),
arrows: visibleArrows,
ticks,
bounds,
chartHeight,
backendLayout,
});
const diagnostics = isRecord(backendGantt?.diagnostics) ? backendGantt.diagnostics : null;
return h(Panel, {
title: "Epoch 甘特图",
@@ -2812,7 +3350,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
h("label", { className: "pipeline-gantt-scale" },
h("span", null,
h("b", null, "时间尺度"),
h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`),
h("em", { "data-testid": "pipeline-gantt-scale-label" }, `${timeScaleConfig.label} · ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
),
h("input", {
type: "range",
@@ -2826,6 +3364,13 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
}),
h("small", null, h("span", null, "全局"), h("span", null, "细节")),
),
activeRun ? h("button", {
type: "button",
className: "ghost-btn",
onClick: exportCurrentGantt,
disabled: visibleNodeIds.length === 0,
"data-testid": "pipeline-export-gantt",
}, "导出甘特图") : null,
activeRun ? h(RawButton, { title: `Pipeline Epoch ${activeRun.runId}`, data: activeRun, onOpen: onRaw, testId: "raw-pipeline-epoch-gantt" }) : null,
),
},
@@ -2837,14 +3382,14 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
h("div", { className: "pipeline-gantt-meta" },
h("span", null, `time ${fmtDate(bounds.startMs)} -> ${fmtDate(bounds.endMs)}`),
h("span", null, `duration ${fmtDurationMs(bounds.durationMs)}`),
h("span", null, `scale ${timeScaleConfig.label} / ${Math.round(Number(timeScaleConfig.pxPerMinute))} px/min`),
h("span", null, `scale ${timeScaleConfig.label} / ${pipelineDisplayPxPerMinute(timeScaleConfig.pxPerMinute)} px/min`),
h("span", null, `layout ${hasBackendLayout ? "backend-y" : "fallback"}`),
diagnostics ? h("span", null, `align ${diagnostics.timeAxisAlignmentOk === false ? "check" : "ok"}`) : null,
h("span", null, `visible ${visibleNodeIds.length}/${allNodeIds.length} nodes`),
runDetailPayload ? h("span", null, `markers ${allMarkers.length}`) : null,
autoHideIdle && hiddenCount > 0 ? h("span", null, `hidden idle ${hiddenCount}`) : null,
),
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "" },
h("div", { className: "pipeline-gantt-viewport", ref: viewportRef, "data-testid": "pipeline-epoch-gantt", "data-pipeline-id": activePipeline?.id || "", "data-run-id": activeRunId },
h("div", { className: "pipeline-gantt-board", style: { gridTemplateColumns, minWidth: `${boardMinWidth}px` } },
h("div", { className: "pipeline-gantt-head time" }, "Time"),
visibleNodeIds.length === 0 ? h("div", { className: "pipeline-gantt-head empty" }, "当前时间窗无工作节点") :
@@ -2885,21 +3430,28 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
),
),
visibleArrows.map((arrow: AnyRecord) => {
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
const targetMarker = markerById.get(String(arrow.targetMarkerId || ""));
if (!sourceMarker || !targetMarker) return null;
const sourceIndex = visibleNodeIds.indexOf(String(sourceMarker.nodeId || ""));
if (!targetMarker) return null;
const sourceMarker = markerById.get(String(arrow.sourceMarkerId || ""));
const sourceNodeId = String(sourceMarker?.nodeId || arrow.sourceNodeId || "");
const sourceIndex = visibleNodeIds.indexOf(sourceNodeId);
const targetIndex = visibleNodeIds.indexOf(String(targetMarker.nodeId || ""));
if (sourceIndex < 0 || targetIndex < 0) return null;
const sourceX = sourceIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
const targetX = targetIndex * pipelineGanttNodeColumnWidth + pipelineGanttNodeColumnWidth / 2;
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1) ?? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout);
const sourceY = pipelineGanttNumber(arrow.sourceY ?? arrow.y1)
?? (sourceMarker ? pipelineGanttMarkerY(sourceMarker, bounds, chartHeight, backendLayout) : pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout));
const targetY = pipelineGanttNumber(arrow.targetY ?? arrow.y2) ?? pipelineGanttMarkerY(targetMarker, bounds, chartHeight, backendLayout);
return h("path", {
key: arrow.id,
className: `pipeline-gantt-arrow ${String(arrow.sourceKind || "").toLowerCase()} ${String(arrow.status || "").toLowerCase()} ${String(arrow.action || "").toLowerCase()}`,
d: pipelineControlArrowPath(sourceX, sourceY, targetX, targetY),
markerEnd: "url(#pipeline-gantt-arrowhead)",
"data-testid": String(arrow.action || "") === "observe" ? "pipeline-gantt-observation-arrow" : "pipeline-gantt-arrow",
"data-source-node-id": String(arrow.sourceNodeId || ""),
"data-target-node-id": String(arrow.targetNodeId || ""),
"data-target-marker-id": String(arrow.targetMarkerId || ""),
"data-action": String(arrow.action || ""),
});
}),
) : null,
@@ -2915,11 +3467,15 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
return h("button", {
key: intervalKey,
type: "button",
className: `pipeline-gantt-bar ${interval.status} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
className: `pipeline-gantt-bar ${interval.status} ${interval.live ? "live" : ""} ${selectedIntervalKey === intervalKey ? "selected" : ""}`,
style: { top: `${top}px`, height: `${height}px` },
title: `${nodeId} ${interval.status} ${fmtDate(interval.startedAt || interval.startMs)} -> ${fmtDate(interval.finishedAt || interval.endMs)}`,
onClick: () => onIntervalSelect(interval),
"data-testid": "pipeline-gantt-line",
"data-node-id": nodeId,
"data-procedure-run-id": String(interval.procedureRunId || ""),
"data-status": String(interval.status || ""),
"data-live": interval.live ? "true" : "false",
},
h("strong", null, interval.status || "working"),
h("span", null, fmtDurationMs(interval.durationMs)),
@@ -2933,6 +3489,7 @@ function PipelineEpochGantt({ epochs, activeRun, activePipeline, pipelineNodes,
title: `${marker.label || "event"} / ${fmtDate(marker.timestampIso || marker.timestamp || marker.ms)}`,
onClick: () => onMarkerSelect(marker),
"data-testid": marker.kind === "prompt" ? "pipeline-gantt-prompt-marker" : "pipeline-gantt-control-marker",
"data-marker-id": String(marker.id || ""),
})),
);
}),
@@ -3098,7 +3655,7 @@ function PipelineNodeControlPanel({ activeRun, pipelineRuns, selectedRunId, onRu
export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyRecord) {
const service = microservices.find((item: any) => item.id === "pipeline") || null;
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, refreshedAt: null });
const [state, setState] = useState({ loading: false, error: "", health: null, snapshot: null, oaDiagnostics: null, refreshedAt: null });
const [selectedPipelineId, setSelectedPipelineId] = useState("");
const [selectedRunId, setSelectedRunId] = useState("");
const [selectedNodeId, setSelectedNodeId] = useState("");
@@ -3121,10 +3678,14 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
if (!silent) setState((prev: any) => ({ ...prev, loading: true, error: "" }));
try {
const snapshotQuery = `__unideskArrayLimit=registry.components:80,runs:${pipelineSnapshotRunLimit}&_=${Date.now()}`;
const snapshot = await requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" });
const [snapshot, oaDiagnostics] = await Promise.all([
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/snapshot?${snapshotQuery}`, { cache: "no-store" }),
requestJson(`${apiBaseUrl}/microservices/pipeline/proxy/api/oa-event-flow/diagnostics?_=${Date.now()}`, { cache: "no-store" })
.catch((error: unknown) => ({ ok: false, error: errorMessage(error, "OA event flow diagnostics failed") })),
]);
if (requestId !== loadRequestRef.current) return;
const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-webui snapshot" };
setState({ loading: false, error: "", health, snapshot, refreshedAt: new Date() });
const health = { ok: snapshot?.ok !== false, service: "pipeline-v2-control snapshot" };
setState({ loading: false, error: "", health, snapshot, oaDiagnostics, refreshedAt: new Date() });
} catch (err) {
if (requestId !== loadRequestRef.current) return;
setState((prev: any) => ({ ...prev, loading: false, error: errorMessage(err, "Pipeline 加载失败") }));
@@ -3146,14 +3707,19 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
const repository = microserviceRepository(service);
const backend = microserviceBackend(service);
const snapshot = state.snapshot || {};
const oaDiagnostics = state.oaDiagnostics || null;
const { components, pipelines, runs } = pipelineSnapshotArrays(snapshot);
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || pipelines[0] || {};
const latestPipelineId = String(runs[0]?.pipelineId || "");
const defaultPipeline = (latestPipelineId ? pipelines.find((pipeline: any) => String(pipeline.id || "") === latestPipelineId) : null) || pipelines[0] || {};
const activePipeline = pipelines.find((pipeline: any) => String(pipeline.id || "") === selectedPipelineId) || defaultPipeline;
const activePipelineId = String(activePipeline.id || "");
const pipelineNodes = pipelineConfigNodes(activePipeline);
const pipelineEdges = pipelineConfigEdges(activePipeline);
const latestRun = pipelineLatestRun(runs, activePipelineId);
const pipelineRuns = pipelineEpochRuns(runs, activePipelineId);
const activeRun = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
const activeRunBase = pipelineRuns.find((run: any) => String(run?.runId || "") === selectedRunId) || latestRun;
const activeRunDetail = String(runDetails.runId || "") === String(activeRunBase?.runId || "") ? pipelineDetailedRun(runDetails.details) : null;
const activeRun = mergePipelineRunSummary(activeRunBase, activeRunDetail);
const activeRunId = String(activeRun?.runId || "");
const selectedNodeConfig = pipelineNodes.find((node: any) => String(node?.id || "") === selectedNodeId) || null;
const selectedNodeRuntime = selectedNodeId ? pipelineRunNodeRecord(activeRun, selectedNodeId) : null;
@@ -3210,9 +3776,12 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
fetchedAt: prev.runId === runId ? prev.fetchedAt : null,
}));
try {
const details = await requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" });
const [details, runSummary] = await Promise.all([
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/node-control/runs/${encodeURIComponent(runId)}?tail=160&view=gantt&scale=${scale}`)}&_=${Date.now()}`, { cache: "no-store" }),
requestJson(`${pipelineProxyPath(apiBaseUrl, `/api/runs/${encodeURIComponent(runId)}`)}?_=${Date.now()}`, { cache: "no-store" }).catch((error: unknown) => ({ ok: false, runSummaryError: errorMessage(error, "抓取评分失败") })),
]);
if (requestId !== runDetailsRequestRef.current) return;
setRunDetails({ runId, scale, loading: false, error: "", details, fetchedAt: new Date() });
setRunDetails({ runId, scale, loading: false, error: "", details: { ...details, run: isRecord(runSummary?.run) ? runSummary.run : undefined, runSummaryError: runSummary?.runSummaryError }, fetchedAt: new Date() });
} catch (err) {
if (requestId !== runDetailsRequestRef.current) return;
setRunDetails((prev: AnyRecord) => ({
@@ -3371,9 +3940,16 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h(MetricCard, { label: "运行记录", value: runCount, hint: `${statusCounts.succeeded || 0} succeeded / ${statusCounts.running || 0} running` }),
h(MetricCard, { label: "OA 记录", value: Array.isArray(latestRun?.submissions) ? latestRun.submissions.length : 0, hint: latestRun?.runId || "latest run" }),
h(MetricCard, { label: "Procedure", value: Array.isArray(latestRun?.procedureRuns) ? latestRun.procedureRuns.length : 0, hint: latestRun?.status || "no run" }),
h(MetricCard, { label: "Score", value: pipelineScoreText(activeRun), hint: activeRun?.runId || "selected epoch", tone: pipelineScoreTone(activeRun) }),
),
h("div", { className: "panel-actions inline-actions" }, h(RawButton, { title: "Pipeline Snapshot", data: snapshot, onOpen: onRaw, testId: "raw-pipeline-snapshot" })),
),
h(Panel, { title: "评分器", eyebrow: activeRun?.runId || "selected epoch" },
h(PipelineScoreBoard, { run: activeRun, onRaw }),
),
h(Panel, { title: "OA 事件流", eyebrow: "100% event-driven diagnostics", className: "pipeline-wide-panel" },
h(PipelineOaEventFlowPanel, { diagnostics: oaDiagnostics, onRaw }),
),
h(Panel, { title: "组件矩阵", eyebrow: `${componentClasses.length} classes` },
componentClasses.length === 0 ? h(EmptyState, { title: "暂无组件", text: "等待 D601 pipeline backend 返回 registry.components" }) :
h("div", { className: "component-strata" }, componentClasses.map((item) => h("article", { key: item.name, className: "component-stratum" },
@@ -3481,6 +4057,7 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
h("span", null, `${pipelines.length} pipelines`),
h("span", null, `source config+components(${components.length})`),
h("span", null, `run ${activeRun?.runId || "--"}`),
h("span", null, `score ${pipelineScoreText(activeRun)}`),
h("span", null, selectedNodeId ? `selected ${selectedNodeId}` : "click node to control"),
),
),
@@ -3507,7 +4084,9 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
}),
h(Panel, { title: "Epoch 列表", eyebrow: `${pipelineRuns.length}/${runCount} preview` },
pipelineRuns.length === 0 ? h(EmptyState, { title: "暂无运行记录", text: "当前 pipeline 在 .state/pipeline-runs 中还没有 epoch。" }) :
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => h("article", {
h("div", { className: "pipeline-run-list" }, pipelineRuns.map((run: any) => {
const cardRun = String(run?.runId || "") === activeRunId ? activeRun : run;
return h("article", {
key: run.runId,
className: `pipeline-run-card ${String(run.runId || "") === activeRunId ? "active" : ""}`,
role: "button",
@@ -3525,14 +4104,16 @@ export function PipelinePage({ microservices, onRaw, apiBaseUrl = "/api" }: AnyR
},
h("div", { className: "node-card-head" }, h("strong", null, pipelineEpochLabel(pipelineRuns, run)), h(StatusBadge, { status: run.status }, run.status || "--")),
h("div", { className: "docker-meta compact" },
h("span", null, run.pipelineId || "--"),
h("span", null, `nodes ${Array.isArray(run.nodes) ? run.nodes.length : 0}`),
h("span", null, `oa ${Array.isArray(run.submissions) ? run.submissions.length : 0}`),
h("span", null, `procedures ${Array.isArray(run.procedureRuns) ? run.procedureRuns.length : 0}`),
h("span", null, cardRun?.pipelineId || "--"),
h("span", null, `nodes ${Array.isArray(cardRun?.nodes) ? cardRun.nodes.length : 0}`),
h("span", null, `oa ${Array.isArray(cardRun?.submissions) ? cardRun.submissions.length : 0}`),
h("span", null, `procedures ${Array.isArray(cardRun?.procedureRuns) ? cardRun.procedureRuns.length : 0}`),
h(PipelineScoreBadge, { run: cardRun }),
),
h("p", { className: "muted paragraph" }, summarizeValue(run.task)),
h("span", { className: "pipeline-run-time" }, fmtDate(run.updatedAt)),
))),
h("p", { className: "muted paragraph" }, summarizeValue(cardRun?.task)),
h("span", { className: "pipeline-run-time" }, fmtDate(cardRun?.updatedAt)),
);
})),
),
h(Panel, { title: "运行材料索引", eyebrow: activeRun?.runId || "selected epoch", className: "pipeline-wide-panel" },
h(PipelineRunMaterialIndex, { activeRun, onRaw }),
@@ -0,0 +1,15 @@
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 \
&& npm install -g @openai/codex@0.128.0 \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY src/components/microservices/codex-queue/package.json ./package.json
COPY src/components/microservices/codex-queue/tsconfig.json ./tsconfig.json
COPY src/components/microservices/codex-queue/src ./src
EXPOSE 4222
CMD ["bun", "run", "src/index.ts"]
@@ -0,0 +1,9 @@
{
"name": "@unidesk/codex-queue",
"private": true,
"type": "module",
"scripts": {
"start": "bun run src/index.ts",
"check": "tsc -p tsconfig.json --noEmit"
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"composite": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["bun", "node"],
"strict": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@unidesk/provider-gateway",
"version": "0.2.8",
"version": "0.2.9",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1350,7 +1350,7 @@ function assertAllowedMicroserviceBase(rawBaseUrl: string): URL {
const baseUrl = new URL(rawBaseUrl);
if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`);
const host = baseUrl.hostname.toLowerCase();
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note"]);
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal", "todo-note", "codex-queue"]);
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
return baseUrl;
}