Share project JSON transport, interaction replies and report selectors. Settle tool progress consistently for direct streams, reconnects and history, preserving failure reasons and stopped state.
504 lines
19 KiB
TypeScript
504 lines
19 KiB
TypeScript
import { requestWorkbenchJson } from "./request-json";
|
||
import { z } from "zod";
|
||
import type { FeatureCollection, Geometry, Position } from "geojson";
|
||
import type { WorkbenchAccess } from "./request-context";
|
||
import { formatDateTime } from "@/shared/utils/date-time";
|
||
import type {
|
||
ScheduledConditionRecord,
|
||
ScheduledWorkOrderItem,
|
||
WorkbenchAlert,
|
||
WorkbenchScenario
|
||
} from "../types";
|
||
import type { AnalysisArtifact, ArtifactLifecycle, ArtifactType } from "../workspace/workspace-model";
|
||
|
||
// Keep legacy analysis values parseable while old servers are rolling forward;
|
||
// they are filtered before the operational workbench model is created.
|
||
const runtimeConditionSchema = z.object({
|
||
id: z.string().trim().min(1),
|
||
task_id: z.enum(["backend-analysis", "backend-schedule"]),
|
||
session_id: z.string().trim().min(1),
|
||
title: z.string().trim().min(1),
|
||
summary: z.string().trim().min(1),
|
||
status: z.enum(["pending", "running", "completed", "warning", "error"]),
|
||
risk_level: z.enum(["normal", "attention", "critical"]),
|
||
scheduled_at: z.string().datetime({ offset: true }),
|
||
updated_at: z.string().datetime({ offset: true }),
|
||
detail: z.string().trim().min(1),
|
||
recommendation: z.string().trim().min(1).nullable(),
|
||
evidence: z.array(z.string().trim().min(1)),
|
||
model_name: z.enum(["TJWater analysis runtime", "TJWater scheduler"])
|
||
});
|
||
|
||
type RuntimeConditionPayload = z.infer<typeof runtimeConditionSchema>;
|
||
type OperationalRuntimeConditionPayload = RuntimeConditionPayload & {
|
||
task_id: "backend-schedule";
|
||
};
|
||
|
||
const runtimeAlertSchema = z.object({
|
||
id: z.string().trim().min(1),
|
||
condition_id: z.string().trim().min(1).nullable(),
|
||
source: z.union([z.object({
|
||
task_type: z.enum(["scheduled_task", "analysis_task", "other_task"]),
|
||
task_id: z.string().trim().min(1),
|
||
run_id: z.string().trim().min(1)
|
||
}), z.object({ kind: z.literal("scada_replay"), replay_id: z.string(), rule_id: z.string(), batch_sequence: z.number().int() })]),
|
||
title: z.string().trim().min(1),
|
||
description: z.string().trim().min(1),
|
||
occurred_at: z.string().datetime({ offset: true }),
|
||
severity: z.enum(["warning", "critical"])
|
||
});
|
||
|
||
const runtimeScenarioSchema = z.object({
|
||
id: z.string().trim().min(1),
|
||
run_id: z.string().trim().min(1),
|
||
name: z.string().trim().min(1),
|
||
description: z.string().trim().min(1),
|
||
status: z.enum(["draft", "active", "review"]),
|
||
run_type: z.string().trim().min(1),
|
||
run_type_label: z.string().trim().min(1),
|
||
run_status: z.string().trim().min(1),
|
||
created_at: z.string().datetime({ offset: true })
|
||
});
|
||
|
||
const runtimeAnalysisResultSchema = z.object({
|
||
result_type: z.string().trim().min(1),
|
||
created_at: z.string().datetime({ offset: true }),
|
||
facts: z.array(z.object({
|
||
label: z.string().trim().min(1),
|
||
value: z.string().trim().min(1)
|
||
}))
|
||
});
|
||
|
||
const geoJsonFeatureCollectionSchema = z.custom<FeatureCollection<Geometry>>(
|
||
isGeoJsonFeatureCollection
|
||
);
|
||
|
||
const runtimeLatestAnalysisSchema = z.object({
|
||
run_id: z.string().trim().min(1),
|
||
name: z.string().trim().min(1),
|
||
run_type: z.string().trim().min(1),
|
||
run_type_label: z.string().trim().min(1),
|
||
status: z.string().trim().min(1),
|
||
status_label: z.string().trim().min(1),
|
||
created_by: z.string().trim().min(1),
|
||
created_at: z.string().datetime({ offset: true }),
|
||
started_at: z.string().datetime({ offset: true }),
|
||
results: z.array(runtimeAnalysisResultSchema),
|
||
map_payload: z.unknown().nullable()
|
||
});
|
||
|
||
const runtimeWorkOrderActivitySchema = z.object({
|
||
sequence: z.number().int().positive(),
|
||
occurred_at: z.string().datetime({ offset: true }),
|
||
actor: z.string().trim().min(1),
|
||
kind: z.enum(["created", "updated", "feedback"]),
|
||
title: z.string().trim().min(1),
|
||
detail: z.string().trim().min(1)
|
||
});
|
||
|
||
const runtimeWorkOrderSchema = z.object({
|
||
id: z.string().trim().min(1),
|
||
code: z.string().trim().min(1),
|
||
scheduled_at: z.string().datetime({ offset: true }),
|
||
title: z.string().trim().min(1),
|
||
summary: z.string().trim().min(1),
|
||
status: z.enum(["pending", "running", "awaiting_reply", "completed"]),
|
||
risk_level: z.enum(["normal", "attention", "critical"]),
|
||
updated_at: z.string().datetime({ offset: true }),
|
||
detail: z.string().trim().min(1),
|
||
recommendation: z.string().trim().min(1).nullable(),
|
||
duration_minutes: z.number().int().positive(),
|
||
source: z.string().trim().min(1),
|
||
dispatcher: z.string().trim().min(1),
|
||
location: z.string().trim().min(1).nullable().default(null),
|
||
assignee: z.string().trim().min(1).nullable().default(null),
|
||
priority: z.enum(["normal", "urgent"]),
|
||
feedback_requirements: z.array(z.string().trim().min(1)).default([]),
|
||
activity: z.array(runtimeWorkOrderActivitySchema).default([])
|
||
});
|
||
|
||
const runtimeSnapshotSchema = z.object({
|
||
availability: z.enum(["ready", "not_configured", "degraded"]),
|
||
source: z.enum(["none", "analysis_runs", "scheduled_tasks"]),
|
||
scenario_availability: z.enum(["ready", "not_configured", "degraded"]),
|
||
artifact_availability: z.enum(["ready", "not_configured", "degraded"]),
|
||
schedule_availability: z.enum(["ready", "not_configured", "degraded"]),
|
||
work_order_availability: z.enum(["ready", "not_configured", "degraded"]),
|
||
alert_availability: z.enum(["ready", "not_configured", "degraded"]),
|
||
generated_at: z.string().datetime({ offset: true }),
|
||
conditions: z.array(runtimeConditionSchema),
|
||
alerts: z.array(runtimeAlertSchema),
|
||
scenarios: z.array(runtimeScenarioSchema),
|
||
latest_analysis: runtimeLatestAnalysisSchema.nullable(),
|
||
work_orders: z.array(runtimeWorkOrderSchema)
|
||
});
|
||
|
||
type ProjectionAvailability = "ready" | "not_configured" | "degraded";
|
||
|
||
export type WorkbenchRuntimeSnapshot = {
|
||
availability: ProjectionAvailability;
|
||
source: "none" | "scheduled_tasks";
|
||
scenarioAvailability: ProjectionAvailability;
|
||
artifactAvailability: ProjectionAvailability;
|
||
scheduleAvailability: ProjectionAvailability;
|
||
workOrderAvailability: ProjectionAvailability;
|
||
alertAvailability: ProjectionAvailability;
|
||
generatedAt: string;
|
||
conditions: ScheduledConditionRecord[];
|
||
alerts: WorkbenchAlert[];
|
||
scenarios: WorkbenchScenario[];
|
||
latestAnalysisArtifact: AnalysisArtifact | null;
|
||
simulationOverlay: FeatureCollection<Geometry> | null;
|
||
workOrders: ScheduledWorkOrderItem[];
|
||
};
|
||
|
||
export type WorkbenchAnalysisProjection = {
|
||
runId: string;
|
||
artifact: AnalysisArtifact;
|
||
simulationOverlay: FeatureCollection<Geometry> | null;
|
||
};
|
||
|
||
|
||
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
|
||
const snapshot = runtimeSnapshotSchema.parse(payload);
|
||
const latestAnalysis = snapshot.latest_analysis
|
||
? projectWorkbenchAnalysis(snapshot.latest_analysis)
|
||
: null;
|
||
const operationalConditions = snapshot.conditions.filter(isOperationalRuntimeCondition);
|
||
|
||
return {
|
||
availability: snapshot.availability,
|
||
source: resolveOperationalRuntimeSource(snapshot, operationalConditions),
|
||
scenarioAvailability: snapshot.scenario_availability,
|
||
artifactAvailability: snapshot.artifact_availability,
|
||
scheduleAvailability: snapshot.schedule_availability,
|
||
workOrderAvailability: snapshot.work_order_availability,
|
||
alertAvailability: snapshot.alert_availability,
|
||
generatedAt: snapshot.generated_at,
|
||
conditions: operationalConditions.map((condition) => ({
|
||
id: condition.id,
|
||
kind: "condition",
|
||
taskId: condition.task_id,
|
||
dataSource: getConditionDataSource(condition.task_id),
|
||
sessionId: condition.session_id,
|
||
title: condition.title,
|
||
summary: condition.summary,
|
||
status: condition.status,
|
||
riskLevel: condition.risk_level,
|
||
scheduledAt: condition.scheduled_at,
|
||
updatedAt: Date.parse(condition.updated_at),
|
||
detail: condition.detail,
|
||
recommendation: condition.recommendation ?? undefined,
|
||
evidence: condition.evidence,
|
||
modelName: condition.model_name
|
||
})),
|
||
alerts: snapshot.alerts.map((alert) => ({
|
||
...(alert.condition_id ? { conditionId: alert.condition_id } : {}),
|
||
id: alert.id,
|
||
title: alert.title,
|
||
description: alert.description,
|
||
source: "kind" in alert.source ? {
|
||
kind: "scada_replay" as const,
|
||
replayId: alert.source.replay_id,
|
||
ruleId: alert.source.rule_id,
|
||
batchSequence: alert.source.batch_sequence
|
||
} : {
|
||
taskType: alert.source.task_type,
|
||
taskId: alert.source.task_id,
|
||
runId: alert.source.run_id
|
||
},
|
||
severity: alert.severity,
|
||
occurredAt: alert.occurred_at,
|
||
time: formatRuntimeAlertTime(alert.occurred_at)
|
||
})),
|
||
scenarios: snapshot.scenarios.map((scenario) => ({
|
||
id: scenario.id,
|
||
name: scenario.name,
|
||
description: scenario.description,
|
||
status: scenario.status,
|
||
runId: scenario.run_id,
|
||
runType: scenario.run_type,
|
||
runStatus: scenario.run_status,
|
||
createdAt: scenario.created_at
|
||
})),
|
||
latestAnalysisArtifact: latestAnalysis?.artifact ?? null,
|
||
simulationOverlay: latestAnalysis?.simulationOverlay ?? null,
|
||
workOrders: snapshot.work_orders.map((workOrder) => ({
|
||
id: `demo-work-order:${workOrder.id}`,
|
||
kind: "work_order",
|
||
code: workOrder.code,
|
||
scheduledAt: workOrder.scheduled_at,
|
||
title: workOrder.title,
|
||
summary: workOrder.summary,
|
||
status: workOrder.status,
|
||
riskLevel: workOrder.risk_level,
|
||
updatedAt: Date.parse(workOrder.updated_at),
|
||
detail: workOrder.detail,
|
||
recommendation: workOrder.recommendation ?? undefined,
|
||
durationMinutes: workOrder.duration_minutes,
|
||
source: workOrder.source,
|
||
dispatcher: workOrder.dispatcher,
|
||
priority: workOrder.priority,
|
||
dataSource: "backend",
|
||
location: workOrder.location ?? undefined,
|
||
assignee: workOrder.assignee ?? undefined,
|
||
feedbackRequirements: workOrder.feedback_requirements,
|
||
activity: workOrder.activity.map((entry) => ({
|
||
sequence: entry.sequence,
|
||
occurredAt: entry.occurred_at,
|
||
actor: entry.actor,
|
||
kind: entry.kind,
|
||
title: entry.title,
|
||
detail: entry.detail
|
||
}))
|
||
}))
|
||
};
|
||
}
|
||
|
||
export async function fetchWorkbenchAnalysisRun(
|
||
runId: string,
|
||
getAccessToken?: WorkbenchAccess,
|
||
signal?: AbortSignal
|
||
): Promise<WorkbenchAnalysisProjection> {
|
||
const normalizedRunId = runId.trim();
|
||
if (!normalizedRunId) throw new Error("分析运行 ID 不能为空");
|
||
const payload = await requestWorkbenchJson(`/api/v1/workbench/analysis-runs/${encodeURIComponent(normalizedRunId)}`, "分析运行成果请求", {
|
||
access: getAccessToken, init: { signal }
|
||
});
|
||
const analysis = runtimeLatestAnalysisSchema.parse(payload);
|
||
if (analysis.run_id !== normalizedRunId) {
|
||
throw new Error("分析运行成果与请求 ID 不一致");
|
||
}
|
||
return projectWorkbenchAnalysis(analysis);
|
||
}
|
||
|
||
function projectWorkbenchAnalysis(
|
||
analysis: z.infer<typeof runtimeLatestAnalysisSchema>
|
||
): WorkbenchAnalysisProjection {
|
||
const parsedSimulationOverlay = analysis.map_payload
|
||
? geoJsonFeatureCollectionSchema.safeParse(analysis.map_payload)
|
||
: null;
|
||
const simulationOverlay = parsedSimulationOverlay?.success
|
||
? parsedSimulationOverlay.data
|
||
: null;
|
||
return {
|
||
runId: analysis.run_id,
|
||
artifact: createPersistedAnalysisArtifact(analysis, simulationOverlay),
|
||
simulationOverlay
|
||
};
|
||
}
|
||
|
||
function createPersistedAnalysisArtifact(
|
||
analysis: z.infer<typeof runtimeLatestAnalysisSchema>,
|
||
simulationOverlay: FeatureCollection<Geometry> | null
|
||
): AnalysisArtifact {
|
||
const resultTypes = [...new Set(analysis.results.map((result) => result.result_type))];
|
||
const completed = ["completed", "succeeded"].includes(analysis.status);
|
||
const lifecycle: ArtifactLifecycle = completed ? "reviewed" : "draft";
|
||
const artifactType = getArtifactType(analysis.run_type);
|
||
const factCount = analysis.results.reduce((total, result) => total + result.facts.length, 0);
|
||
const resultSummary = analysis.results.length > 0
|
||
? `后端记录状态为${analysis.status_label},已持久化 ${analysis.results.length} 条分析结果。`
|
||
: `后端记录状态为${analysis.status_label},当前未查询到独立结果记录。`;
|
||
|
||
return {
|
||
id: `persisted-analysis-${analysis.run_id}`,
|
||
origin: "backend",
|
||
type: artifactType,
|
||
lifecycle,
|
||
revision: 1,
|
||
title: analysis.name,
|
||
subtitle: `${analysis.run_type_label} · 项目后端分析运行`,
|
||
summary: resultSummary,
|
||
scope: `运行 ${analysis.run_id}`,
|
||
confidence: "后端未提供",
|
||
generatedAt: formatRuntimeDateTime(analysis.created_at),
|
||
analyticalTimeRange: {
|
||
start: analysis.started_at,
|
||
end: analysis.created_at
|
||
},
|
||
preferredView: simulationOverlay ? "map_split" : "focus",
|
||
mapRelation: simulationOverlay ? "required" : "none",
|
||
...(simulationOverlay ? { spatialOverlay: simulationOverlay } : {}),
|
||
blocks: [
|
||
{
|
||
id: `persisted-analysis-${analysis.run_id}-metrics`,
|
||
kind: "metric-grid",
|
||
title: "运行摘要",
|
||
span: 12,
|
||
metrics: [
|
||
{
|
||
label: "后端状态",
|
||
value: analysis.status_label,
|
||
detail: analysis.status,
|
||
tone: getStatusTone(analysis.status)
|
||
},
|
||
{
|
||
label: "结果记录",
|
||
value: String(analysis.results.length),
|
||
detail: "analysis.results",
|
||
tone: analysis.results.length > 0 ? "success" : "neutral"
|
||
},
|
||
{
|
||
label: "结果类型",
|
||
value: String(resultTypes.length),
|
||
detail: resultTypes.join("、") || "暂无",
|
||
tone: "info"
|
||
},
|
||
{
|
||
label: "成果事实",
|
||
value: String(factCount),
|
||
detail: factCount > 0 ? "后端已识别字段" : "暂无已识别字段",
|
||
tone: factCount > 0 ? "success" : "neutral"
|
||
},
|
||
{
|
||
label: "创建人",
|
||
value: analysis.created_by,
|
||
detail: analysis.run_type_label,
|
||
tone: "neutral"
|
||
}
|
||
]
|
||
},
|
||
{
|
||
id: `persisted-analysis-${analysis.run_id}-metadata`,
|
||
kind: "narrative",
|
||
title: "后端运行记录",
|
||
span: 12,
|
||
paragraphs: [
|
||
`运行 ID:${analysis.run_id}`,
|
||
`分析类型:${analysis.run_type_label}(${analysis.run_type})`,
|
||
`创建人:${analysis.created_by};状态:${analysis.status_label}。`
|
||
]
|
||
},
|
||
...(analysis.results.length > 0
|
||
? [{
|
||
id: `persisted-analysis-${analysis.run_id}-results`,
|
||
kind: "data-table" as const,
|
||
title: "持久化结果索引",
|
||
description: "来自项目 BizDB analysis.results 的结果记录",
|
||
span: 12 as const,
|
||
columns: [
|
||
{ key: "type", label: "结果类型" },
|
||
{ key: "createdAt", label: "写入时间" },
|
||
{ key: "facts", label: "后端成果事实" }
|
||
],
|
||
rows: analysis.results.map((result) => ({
|
||
type: result.result_type,
|
||
createdAt: formatRuntimeDateTime(result.created_at),
|
||
facts: result.facts.length > 0
|
||
? result.facts.map((fact) => `${fact.label}:${fact.value}`).join(";")
|
||
: "未提供已识别字段"
|
||
}))
|
||
}]
|
||
: [])
|
||
],
|
||
sources: [
|
||
"项目 BizDB · analysis.runs",
|
||
...(analysis.results.length > 0 ? ["项目 BizDB · analysis.results"] : [])
|
||
],
|
||
limitations: [
|
||
factCount > 0
|
||
? "当前投影展示后端持久化元数据、结果索引和已识别的成果字段;未透传任意结果载荷。"
|
||
: "当前投影只展示后端持久化元数据与结果索引。",
|
||
"后端记录没有提供综合置信度。"
|
||
],
|
||
actions: []
|
||
};
|
||
}
|
||
|
||
function getArtifactType(runType: string): ArtifactType {
|
||
if (["burst_detection", "burst_location", "dma_leak_identification"].includes(runType)) {
|
||
return "diagnosis";
|
||
}
|
||
if (runType === "sensor_placement") return "metric_analysis";
|
||
return "simulation";
|
||
}
|
||
|
||
function getStatusTone(status: string) {
|
||
if (["completed", "succeeded"].includes(status)) return "success" as const;
|
||
if (["failed", "error"].includes(status)) return "danger" as const;
|
||
if (status === "running") return "info" as const;
|
||
return "warning" as const;
|
||
}
|
||
|
||
function formatRuntimeAlertTime(value: string) {
|
||
return formatRuntimeDateTime(value).replace("/", "-");
|
||
}
|
||
|
||
function getConditionDataSource(
|
||
_taskId: OperationalRuntimeConditionPayload["task_id"]
|
||
) {
|
||
return "scheduled_tasks" as const;
|
||
}
|
||
|
||
function isOperationalRuntimeCondition(
|
||
condition: RuntimeConditionPayload
|
||
): condition is OperationalRuntimeConditionPayload {
|
||
return condition.task_id === "backend-schedule";
|
||
}
|
||
|
||
function resolveOperationalRuntimeSource(
|
||
snapshot: z.infer<typeof runtimeSnapshotSchema>,
|
||
conditions: OperationalRuntimeConditionPayload[]
|
||
): WorkbenchRuntimeSnapshot["source"] {
|
||
if (snapshot.source === "scheduled_tasks") return "scheduled_tasks";
|
||
return conditions.length > 0 ? "scheduled_tasks" : "none";
|
||
}
|
||
|
||
function formatRuntimeDateTime(value: string) {
|
||
return formatDateTime(value, {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit"
|
||
});
|
||
}
|
||
|
||
function isGeoJsonFeatureCollection(value: unknown): value is FeatureCollection<Geometry> {
|
||
if (!isRecord(value) || value.type !== "FeatureCollection" || !Array.isArray(value.features)) {
|
||
return false;
|
||
}
|
||
return value.features.every((feature) =>
|
||
isRecord(feature)
|
||
&& feature.type === "Feature"
|
||
&& (feature.properties === null || isRecord(feature.properties))
|
||
&& (feature.geometry === null || isGeoJsonGeometry(feature.geometry))
|
||
);
|
||
}
|
||
|
||
function isGeoJsonGeometry(value: unknown): value is Geometry {
|
||
if (!isRecord(value) || typeof value.type !== "string") return false;
|
||
if (value.type === "GeometryCollection") {
|
||
return Array.isArray(value.geometries) && value.geometries.every(isGeoJsonGeometry);
|
||
}
|
||
const coordinates = value.coordinates;
|
||
if (value.type === "Point") return isPosition(coordinates);
|
||
if (value.type === "MultiPoint" || value.type === "LineString") {
|
||
return isPositionArray(coordinates);
|
||
}
|
||
if (value.type === "MultiLineString" || value.type === "Polygon") {
|
||
return Array.isArray(coordinates) && coordinates.every(isPositionArray);
|
||
}
|
||
if (value.type === "MultiPolygon") {
|
||
return Array.isArray(coordinates)
|
||
&& coordinates.every((polygon) =>
|
||
Array.isArray(polygon) && polygon.every(isPositionArray)
|
||
);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function isPosition(value: unknown): value is Position {
|
||
return Array.isArray(value)
|
||
&& value.length >= 2
|
||
&& value.every((coordinate) => typeof coordinate === "number" && Number.isFinite(coordinate));
|
||
}
|
||
|
||
function isPositionArray(value: unknown): value is Position[] {
|
||
return Array.isArray(value) && value.every(isPosition);
|
||
}
|
||
|
||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||
}
|