feat(workbench): integrate operational workflows and map state

This commit is contained in:
2026-09-24 04:34:46 +08:00
parent 1752b9ef84
commit 436d5500f1
76 changed files with 5081 additions and 1937 deletions
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseOperationalTask } from "./operational-task-client";
vi.mock("./project-client", () => ({
findOwnedWorkbenchProject: vi.fn(async () => ({ project: { project_id: "project" }, accessToken: "test" })),
createWorkbenchHeaders: vi.fn(() => ({})),
getServerError: vi.fn(() => "请求失败")
}));
const id = "324ff7ee-c2d4-5184-a84b-48fb14a2933e";
const time = "2026-09-23T01:00:00Z";
function task(generation: number | null | undefined = 2) {
const snapshot = { revision: 1, source_session_id: "session", published_at: time, published_by: "agent",
...(generation === undefined ? {} : { execution_generation: generation }) };
return {
project_id: id, task_id: id, task_type: "event_investigation", workflow: "water_quality_complaint",
lifecycle_status: "waiting_human", active_run: null,
status: "waiting_human", priority: "normal", revision: 1, execution_generation: 2,
execution_request_id: id, title: "水质投诉", summary: "调查中",
source: { kind: "external_system", external_id: null, external_revision: null, event_kind: null },
trigger: { kind: "event", occurred_at: time, detail: "投诉上报" },
scheduled_for: null, agent_session_id: "session", agent_lease_expires_at: null,
current_plan_revision: 1, current_report_revision: 1, created_at: time, updated_at: time,
latest_plan: { ...snapshot, plan: { schema: "execution-plan@1", title: "调查", reason: "投诉",
nodes: [{ id: "check", title: "核查", owner: "agent", status: "completed", detail: "已核查", evidence_refs: [], action_refs: [] }], edges: [] } },
latest_report: { ...snapshot, report: { title: "调查报告", current_conclusion: "待采样",
conclusion_boundary: "尚无现场证据", evidence_refs: [], recommendations: [], pending_items: ["采样"] } },
evidence: [], actions: [], permissions: { can_publish_artifacts: false, can_propose_actions: false,
can_sync_external_feedback: false, can_decide_work_orders: false, can_execute_work_orders: false,
can_decide_commands: false, can_execute_commands: false }
};
}
afterEach(() => vi.unstubAllGlobals());
describe("operational task contract", () => {
it("accepts execution generation on both plan and report", () => {
const parsed = parseOperationalTask(task());
expect(parsed.latest_plan).toHaveProperty("execution_generation", 2);
expect(parsed.latest_report).toHaveProperty("execution_generation", 2);
});
it("accepts legacy null or missing snapshot generation", () => {
expect(() => parseOperationalTask(task(null))).not.toThrow();
const legacy = task();
Reflect.deleteProperty(legacy.latest_plan, "execution_generation");
Reflect.deleteProperty(legacy.latest_report, "execution_generation");
expect(() => parseOperationalTask(legacy)).not.toThrow();
});
it("accepts a task without any plan or report", () => {
expect(parseOperationalTask({ ...task(), latest_plan: null, latest_report: null }).latest_report).toBeNull();
});
it("keeps unknown fields and invalid generations rejected", () => {
expect(() => parseOperationalTask(task(0))).toThrow();
const value = task();
expect(() => parseOperationalTask({ ...value, latest_plan: { ...value.latest_plan, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, unexpected: true } })).toThrow();
expect(() => parseOperationalTask({ ...value, latest_report: { ...value.latest_report, execution_generation: -1 } })).toThrow();
});
});
@@ -0,0 +1,395 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
const taskStatusSchema = z.enum(["queued", "running", "waiting_human", "completed", "failed", "cancelled"]);
const taskPrioritySchema = z.enum(["normal", "urgent"]);
const workbenchDateTimeSchema = z.string().datetime({ offset: true });
const taskSourceSchema = z.object({
kind: z.enum(["schedule", "external_system", "operator", "agent"]),
external_id: z.string().min(1).nullable(),
external_revision: z.number().int().positive().nullable(),
event_kind: z.string().min(1).nullable()
}).strict();
const taskTriggerSchema = z.object({
kind: z.enum(["schedule", "external_sync", "event", "manual"]),
occurred_at: workbenchDateTimeSchema,
detail: z.string().min(1)
}).strict();
const operationalTaskRunSchema = z.object({
run_id: z.string().uuid(),
task_id: z.string().uuid(),
generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
status: z.enum(["queued", "claimed", "running", "succeeded", "failed", "timed_out", "cancelled"]),
revision: z.number().int().positive(),
agent_session_id: z.string().min(1).nullable(),
lease_expires_at: workbenchDateTimeSchema.nullable(),
heartbeat_at: workbenchDateTimeSchema.nullable(),
started_at: workbenchDateTimeSchema.nullable(),
finished_at: workbenchDateTimeSchema.nullable(),
failure_code: z.string().min(1).nullable(),
failure_detail: z.string().min(1).nullable(),
dispatch_attempts: z.number().int().nonnegative(),
next_dispatch_at: workbenchDateTimeSchema,
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const taskSummarySchema = z.object({
project_id: z.string().uuid(),
task_id: z.string().uuid(),
task_type: z.enum(["scheduled_flow", "external_work_order", "event_investigation"]),
workflow: z.string().min(1),
lifecycle_status: z.enum(["open", "waiting_human", "completed", "failed", "cancelled"]),
active_run: operationalTaskRunSchema.nullable(),
status: taskStatusSchema,
priority: taskPrioritySchema,
revision: z.number().int().positive(),
execution_generation: z.number().int().positive(),
execution_request_id: z.string().uuid(),
title: z.string().min(1),
summary: z.string().min(1),
source: taskSourceSchema,
trigger: taskTriggerSchema,
scheduled_for: workbenchDateTimeSchema.nullable(),
agent_session_id: z.string().min(1).nullable(),
agent_lease_expires_at: workbenchDateTimeSchema.nullable(),
current_plan_revision: z.number().int().positive().nullable(),
current_report_revision: z.number().int().positive().nullable(),
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const evidenceSchema = z.object({
evidence_id: z.string().min(1),
kind: z.enum(["system_data", "external_feedback", "human_input", "synthetic"]),
summary: z.string().min(1),
source_label: z.string().min(1),
source_ref: z.string().min(1).nullable(),
observed_at: workbenchDateTimeSchema,
recorded_at: workbenchDateTimeSchema,
recorded_by: z.string().min(1),
has_snapshot: z.boolean()
}).strict();
const planNodeSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
owner: z.enum(["agent", "human"]),
status: z.enum(["pending", "waiting", "running", "completed", "failed", "skipped", "cancelled"]),
detail: z.string().min(1),
evidence_refs: z.array(z.string().min(1)),
action_refs: z.array(z.string().uuid())
}).strict();
const planInputSchema = z.object({
schema: z.literal("execution-plan@1"),
title: z.string().min(1),
reason: z.string().min(1),
nodes: z.array(planNodeSchema).min(1).max(100),
edges: z.array(z.object({
from: z.string().min(1),
to: z.string().min(1),
condition: z.string().min(1).nullable()
}).strict()).max(300)
}).strict();
const planSnapshotSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
source_session_id: z.string().min(1),
published_at: workbenchDateTimeSchema,
published_by: z.string().min(1),
plan: planInputSchema
}).strict();
const reportInputSchema = z.object({
title: z.string().min(1),
current_conclusion: z.string().min(1),
conclusion_boundary: z.string().min(1),
evidence_refs: z.array(z.string().min(1)),
recommendations: z.array(z.string().min(1)),
pending_items: z.array(z.string().min(1))
}).strict();
const reportRevisionSchema = z.object({
revision: z.number().int().positive(),
execution_generation: z.number().int().positive().nullish(),
source_session_id: z.string().min(1),
published_at: workbenchDateTimeSchema,
published_by: z.string().min(1),
report: reportInputSchema
}).strict();
const actionReceiptSchema = z.object({
adapter: z.string().min(1),
receipt_id: z.string().min(1),
submitted_at: workbenchDateTimeSchema,
completed_at: workbenchDateTimeSchema.nullable(),
external_status: z.string().min(1),
detail: z.string().min(1)
}).strict();
const actionSchema = z.object({
action_id: z.string().uuid(),
task_id: z.string().uuid(),
version: z.number().int().positive(),
kind: z.enum(["create_work_order", "issue_command"]),
reason: z.string().min(1),
payload: z.record(z.string(), z.unknown()),
evidence_refs: z.array(z.string().min(1)),
node_id: z.string().min(1).nullable(),
required_approval_permission: z.string().min(1),
required_execution_permission: z.string().min(1),
approval_status: z.enum(["pending", "approved", "rejected"]),
execution_status: z.enum(["not_submitted", "submitted", "succeeded", "failed", "unsupported", "unknown"]),
execution_request_id: z.string().uuid().nullable(),
proposed_by: z.string().min(1),
proposed_at: workbenchDateTimeSchema,
decided_by: z.string().min(1).nullable(),
decided_at: workbenchDateTimeSchema.nullable(),
decision_comment: z.string().min(1).nullable(),
executed_by: z.string().min(1).nullable(),
executed_at: workbenchDateTimeSchema.nullable(),
receipt: actionReceiptSchema.nullable(),
execution_detail: z.string().min(1).nullable()
}).strict();
const taskPermissionsSchema = z.object({
can_publish_artifacts: z.boolean(),
can_propose_actions: z.boolean(),
can_sync_external_feedback: z.boolean(),
can_decide_work_orders: z.boolean(),
can_execute_work_orders: z.boolean(),
can_decide_commands: z.boolean(),
can_execute_commands: z.boolean()
}).strict();
const externalWorkOrderStageSchema = z.object({
id: z.string().min(1),
sequence: z.number().int().positive(),
title: z.string().min(1),
status: z.enum(["done", "current", "pending", "blocked", "skipped"]),
owner: z.string().min(1),
time_label: z.string().min(1),
description: z.string().min(1)
}).strict();
const externalWorkOrderSubtaskSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["pending", "running", "blocked", "completed", "cancelled"]),
scheduled_at: workbenchDateTimeSchema,
location: z.string().min(1),
assignee: z.string().min(1),
duration_minutes: z.number().int().positive(),
reply_window_minutes: z.number().int().nonnegative(),
stages: z.array(externalWorkOrderStageSchema).min(1),
reply_requirements: z.array(z.string().min(1))
}).strict();
const externalWorkOrderSnapshotSchema = z.object({
work_order_id: z.string().uuid(),
revision: z.number().int().positive(),
title: z.string().min(1),
summary: z.string().min(1),
status: z.enum(["draft", "open", "in_progress", "awaiting_reply", "completed", "cancelled"]),
priority: z.enum(["low", "normal", "high", "urgent"]),
assignee: z.string().min(1).nullable(),
scheduled_at: workbenchDateTimeSchema.nullable(),
work_order_type: z.enum(["general", "water_quality_complaint", "burst_report"]),
location: z.string().min(1).nullable(),
scenario_code: z.string().min(1).nullable(),
subtasks: z.array(externalWorkOrderSubtaskSchema).min(1).nullable(),
origin: z.literal("synthetic"),
updated_at: workbenchDateTimeSchema
}).strict();
const operationalTaskSchema = taskSummarySchema.extend({
latest_plan: planSnapshotSchema.nullable(),
latest_report: reportRevisionSchema.nullable(),
evidence: z.array(evidenceSchema),
actions: z.array(actionSchema),
permissions: taskPermissionsSchema,
work_order_snapshot: externalWorkOrderSnapshotSchema.nullable().default(null)
}).strict();
const notificationSchema = z.object({
notification_id: z.string().uuid(),
task_id: z.string().uuid(),
kind: z.enum(["task_created", "action_pending", "report_updated", "task_completed", "task_failed", "run_delivery_degraded"]),
title: z.string().min(1),
description: z.string().min(1),
status: z.enum(["unread", "read"]),
version: z.number().int().positive(),
required_permission: z.string().min(1).nullable(),
created_at: workbenchDateTimeSchema,
read_at: workbenchDateTimeSchema.nullable(),
read_by: z.string().min(1).nullable()
}).strict();
const notificationPageSchema = z.object({
items: z.array(notificationSchema),
limit: z.number().int().positive(),
offset: z.number().int().nonnegative(),
next_offset: z.number().int().nonnegative().nullable()
}).strict();
const scheduleSchema = z.object({
schedule_id: z.string().uuid(),
schedule_key: z.string().min(1),
title: z.string().min(1),
summary: z.string().min(1),
interval_seconds: z.number().int().min(60).max(31_536_000),
next_run_at: workbenchDateTimeSchema,
priority: taskPrioritySchema,
enabled: z.boolean(),
version: z.number().int().positive(),
created_at: workbenchDateTimeSchema,
updated_at: workbenchDateTimeSchema
}).strict();
const schedulePageSchema = z.object({ items: z.array(scheduleSchema) }).strict();
const taskDataSnapshotSchema = z.object({
source: z.enum(["workbench_runtime", "scada_devices", "scada_readings", "peripheral_points", "peripheral_work_orders", "peripheral_observations", "peripheral_test_data"]),
source_ref: z.string().min(1),
queried_at: workbenchDateTimeSchema,
origin: z.enum(["system_data", "synthetic"]),
data: z.unknown(),
truncated: z.boolean()
}).strict();
export type OperationalTaskSummary = z.infer<typeof taskSummarySchema>;
export type OperationalTask = z.infer<typeof operationalTaskSchema>;
export type OperationalTaskPlan = z.infer<typeof planInputSchema>;
export type OperationalTaskAction = z.infer<typeof actionSchema>;
export type OperationalTaskEvidence = z.infer<typeof evidenceSchema>;
export type OperationalTaskNotification = z.infer<typeof notificationSchema>;
export type OperationalTaskSchedule = z.infer<typeof scheduleSchema>;
export type OperationalTaskDataSnapshot = z.infer<typeof taskDataSnapshotSchema>;
export type ExternalWorkOrderSnapshot = z.infer<typeof externalWorkOrderSnapshotSchema>;
export type OperationalTaskDecision = "approved" | "rejected";
export function parseOperationalTask(value: unknown) {
return operationalTaskSchema.parse(value);
}
export function parseOperationalTaskSummary(value: unknown) {
return taskSummarySchema.parse(value);
}
export function parseOperationalTaskNotification(value: unknown) {
return notificationSchema.parse(value);
}
export type CreateOperationalTaskInput = {
request_id: string;
task_type: OperationalTask["task_type"];
workflow: string;
title: string;
summary: string;
priority: OperationalTask["priority"];
source: OperationalTask["source"];
trigger: OperationalTask["trigger"];
scheduled_for?: string;
agent_session_id?: string;
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
async function operationalTaskRequest(path: string, getAccessToken?: AccessTokenProvider, init?: RequestInit) {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, init?.signal ?? undefined);
const headers = new Headers(createWorkbenchHeaders(accessToken, project.project_id));
if (init?.body) headers.set("Content-Type", "application/json");
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1${path}`, { ...init, headers });
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工况任务请求"));
return payload;
}
export async function fetchOperationalTask(taskId: string, getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return operationalTaskSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}`,
getAccessToken,
{ signal }
));
}
export async function createOperationalTask(input: CreateOperationalTaskInput, getAccessToken?: AccessTokenProvider) {
return operationalTaskSchema.parse(await operationalTaskRequest(
"/operational-tasks",
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function decideOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
decision: OperationalTaskDecision;
comment?: string;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/decision`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function executeOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/execute`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskNotifications(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return notificationPageSchema.parse(await operationalTaskRequest(
"/operational-task-notifications?unread_only=true&limit=100&offset=0",
getAccessToken,
{ signal }
)).items;
}
export async function acknowledgeOperationalTaskNotification(notificationId: string, version: number, requestId: string, getAccessToken?: AccessTokenProvider) {
await operationalTaskRequest(
`/operational-task-notifications/${encodeURIComponent(notificationId)}/acknowledge`,
getAccessToken,
{ method: "POST", body: JSON.stringify({ request_id: requestId, expected_version: version }) }
);
}
export async function fetchOperationalTaskSchedules(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) {
return schedulePageSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
{ signal }
)).items;
}
export async function createOperationalTaskSchedule(input: {
request_id: string;
schedule_key: string;
title: string;
summary: string;
interval_seconds: number;
next_run_at: string;
priority: "normal" | "urgent";
}, getAccessToken?: AccessTokenProvider) {
return scheduleSchema.parse(await operationalTaskRequest(
"/operational-tasks/schedules",
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function reconcileOperationalTaskAction(taskId: string, actionId: string, input: {
request_id: string;
expected_version: number;
}, getAccessToken?: AccessTokenProvider) {
return actionSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/actions/${encodeURIComponent(actionId)}/reconcile`,
getAccessToken,
{ method: "POST", body: JSON.stringify(input) }
));
}
export async function fetchOperationalTaskEvidenceSnapshot(taskId: string, evidenceId: string, getAccessToken?: AccessTokenProvider) {
return taskDataSnapshotSchema.parse(await operationalTaskRequest(
`/operational-tasks/${encodeURIComponent(taskId)}/evidence/${encodeURIComponent(evidenceId)}/snapshot`,
getAccessToken
));
}
export function createOperationalRequestId() {
return crypto.randomUUID();
}
@@ -36,6 +36,37 @@ describe("workbench project client", () => {
await expect(findOwnedWorkbenchProject()).rejects.toThrow("授权项目");
});
it("does not share a caller-bound abort signal with other project consumers", async () => {
const projectPage = JSON.stringify({
items: [{ project_id: "supply", gs_workspace: "tjwater_next", status: "active" }],
total: 1,
limit: 1000,
offset: 0
});
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => {
if (!init?.signal) {
return Promise.resolve(new Response(projectPage, { status: 200 }));
}
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
});
vi.stubGlobal("fetch", fetchMock);
const getAccessToken = async () => null;
const controller = new AbortController();
const cancelled = findOwnedWorkbenchProject(getAccessToken, controller.signal);
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
const active = findOwnedWorkbenchProject(getAccessToken);
controller.abort();
await expect(cancelled).rejects.toMatchObject({ name: "AbortError" });
await expect(active).resolves.toMatchObject({ project: { project_id: "supply" } });
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("loads the authoritative project extent and access identity independently", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const path = new URL(String(input)).pathname;
+30 -12
View File
@@ -69,24 +69,42 @@ export type WorkbenchProjectPresentation = {
};
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const projectContextPromises = new WeakMap<AccessTokenProvider, Promise<WorkbenchProject>>();
export async function findOwnedWorkbenchProject(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> {
const accessToken = await getAccessToken?.();
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const projects = projectPageSchema.parse(payload);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return { project, accessToken };
const cacheable = Boolean(getAccessToken && !signal);
const existing = cacheable && getAccessToken
? projectContextPromises.get(getAccessToken)
: undefined;
if (existing) return { project: await existing, accessToken };
const loadProject = async () => {
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, {
headers: createWorkbenchHeaders(accessToken),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询"));
const projects = projectPageSchema.parse(payload);
const project = projects.items.find(
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
);
if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`);
return project;
};
const pending = loadProject();
if (cacheable && getAccessToken) projectContextPromises.set(getAccessToken, pending);
try {
return { project: await pending, accessToken };
} catch (error) {
if (cacheable && getAccessToken && projectContextPromises.get(getAccessToken) === pending) {
projectContextPromises.delete(getAccessToken);
}
throw error;
}
}
export async function fetchWorkbenchProjectPresentation(
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchWorkbenchAnalysisRun,
fetchWorkbenchRuntimeSnapshot
parseWorkbenchRuntimeSnapshot
} from "./workbench-runtime-client";
const projectPage = {
@@ -89,16 +89,8 @@ const runtimeSnapshot = {
describe("workbench runtime API client", () => {
afterEach(() => vi.unstubAllGlobals());
it("resolves the project and maps a persisted scheduled condition", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify(runtimeSnapshot), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot(
vi.fn().mockResolvedValue("access-token")
);
it("maps a persisted scheduled condition", () => {
const snapshot = parseWorkbenchRuntimeSnapshot(runtimeSnapshot);
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.conditions).toEqual([
@@ -140,17 +132,10 @@ describe("workbench runtime API client", () => {
})
]));
expect(snapshot.simulationOverlay?.features).toHaveLength(1);
const runtimeHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
expect(runtimeHeaders.get("Authorization")).toBe("Bearer access-token");
expect(runtimeHeaders.get("X-Project-Id")).toBe("project-1");
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/workbench/runtime");
});
it("keeps legacy analysis conditions out of the operational task feed", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
it("keeps legacy analysis conditions out of the operational task feed", () => {
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
source: "analysis_runs",
conditions: [{
@@ -168,10 +153,7 @@ describe("workbench runtime API client", () => {
evidence: ["运行 ID:analysis-1"],
model_name: "TJWater analysis runtime"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.source).toBe("none");
expect(snapshot.conditions).toEqual([]);
@@ -182,16 +164,8 @@ describe("workbench runtime API client", () => {
expect(snapshot.scenarios).toHaveLength(1);
});
it("rejects an untyped runtime payload instead of treating it as real data", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({ ...runtimeSnapshot, source: "production" }), {
status: 200
}));
vi.stubGlobal("fetch", fetchMock);
await expect(fetchWorkbenchRuntimeSnapshot()).rejects.toThrow();
it("rejects an untyped runtime payload instead of treating it as real data", () => {
expect(() => parseWorkbenchRuntimeSnapshot({ ...runtimeSnapshot, source: "production" })).toThrow();
});
it("loads the requested analysis run instead of reusing the latest result", async () => {
@@ -247,11 +221,8 @@ describe("workbench runtime API client", () => {
expect(headers.get("X-Project-Id")).toBe("project-1");
});
it("drops invalid backend map geometry without discarding other real sections", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
it("drops invalid backend map geometry without discarding other real sections", () => {
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
latest_analysis: {
...runtimeSnapshot.latest_analysis,
@@ -264,10 +235,7 @@ describe("workbench runtime API client", () => {
}]
}
}
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.source).toBe("scheduled_tasks");
expect(snapshot.scenarios).toHaveLength(1);
@@ -276,7 +244,7 @@ describe("workbench runtime API client", () => {
expect(snapshot.latestAnalysisArtifact).not.toHaveProperty("spatialOverlay");
});
it("maps provisioned backend work orders without using the demo shape", async () => {
it("maps provisioned backend work orders without using the demo shape", () => {
const backendWorkOrder = {
id: "wo-1",
code: "WO-20260917-001",
@@ -334,21 +302,15 @@ describe("workbench runtime API client", () => {
reply_requirements: ["回传阀门开度"]
}]
};
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
work_order_availability: "ready",
work_orders: [backendWorkOrder]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.workOrderAvailability).toBe("ready");
expect(snapshot.workOrders).toEqual([expect.objectContaining({
id: "wo-1",
id: "demo-work-order:wo-1",
kind: "work_order",
code: "WO-20260917-001",
status: "completed",
@@ -364,11 +326,8 @@ describe("workbench runtime API client", () => {
})]);
});
it("maps a provisioned backend alert without inventing a condition link", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response(JSON.stringify(projectPage), { status: 200 }))
.mockResolvedValueOnce(new Response(JSON.stringify({
it("maps a provisioned backend alert without inventing a condition link", () => {
const snapshot = parseWorkbenchRuntimeSnapshot({
...runtimeSnapshot,
alert_availability: "ready",
alerts: [{
@@ -384,10 +343,7 @@ describe("workbench runtime API client", () => {
occurred_at: "2026-09-17T02:29:00Z",
severity: "warning"
}]
}), { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const snapshot = await fetchWorkbenchRuntimeSnapshot();
});
expect(snapshot.alertAvailability).toBe("ready");
expect(snapshot.alerts).toEqual([expect.objectContaining({
@@ -202,19 +202,7 @@ export type WorkbenchAnalysisProjection = {
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
export async function fetchWorkbenchRuntimeSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchRuntimeSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/runtime`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
throw new Error(getServerError(payload, response.status, "工作台运行态请求"));
}
export function parseWorkbenchRuntimeSnapshot(payload: unknown): WorkbenchRuntimeSnapshot {
const snapshot = runtimeSnapshotSchema.parse(payload);
const latestAnalysis = snapshot.latest_analysis
? projectWorkbenchAnalysis(snapshot.latest_analysis)
@@ -274,7 +262,7 @@ export async function fetchWorkbenchRuntimeSnapshot(
latestAnalysisArtifact: latestAnalysis?.artifact ?? null,
simulationOverlay: latestAnalysis?.simulationOverlay ?? null,
workOrders: snapshot.work_orders.map((workOrder) => ({
id: workOrder.id,
id: `demo-work-order:${workOrder.id}`,
kind: "work_order",
code: workOrder.code,
scheduledAt: workOrder.scheduled_at,
@@ -289,6 +277,7 @@ export async function fetchWorkbenchRuntimeSnapshot(
source: workOrder.source,
dispatcher: workOrder.dispatcher,
priority: workOrder.priority,
dataSource: "backend",
subtasks: workOrder.subtasks.map((subtask) => ({
id: subtask.id,
title: subtask.title,
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { consumeWorkbenchEvents } from "./workbench-sync-client";
vi.mock("./project-client", () => ({
findOwnedWorkbenchProject: vi.fn(async () => ({
project: { project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e" },
accessToken: "test-token"
})),
createWorkbenchHeaders: vi.fn((token: string, projectId: string) => new Headers({
Authorization: `Bearer ${token}`,
"X-Project-Id": projectId
})),
getServerError: vi.fn(() => "请求失败")
}));
afterEach(() => vi.unstubAllGlobals());
describe("workbench event stream", () => {
it("sends the authenticated cursor and stops for a snapshot reset", async () => {
const payload = "event: reset_required\r\ndata: {}\r\n\r\n";
const bytes = new TextEncoder().encode(payload);
let request: Request | undefined;
vi.stubGlobal("fetch", vi.fn(async (input, init) => {
request = new Request(input, init);
return new Response(new ReadableStream({
start(controller) {
controller.enqueue(bytes.slice(0, 17));
controller.enqueue(bytes.slice(17));
controller.close();
}
}), { status: 200, headers: { "Content-Type": "text/event-stream" } });
}));
const onReset = vi.fn(async () => undefined);
const onEvents = vi.fn();
await expect(consumeWorkbenchEvents({
after: 41,
signal: new AbortController().signal,
onReset,
onEvents
})).resolves.toBe("reset");
expect(request?.headers.get("Authorization")).toBe("Bearer test-token");
expect(request?.headers.get("X-Project-Id")).toBe("324ff7ee-c2d4-5184-a84b-48fb14a2933e");
expect(request?.headers.get("Last-Event-ID")).toBe("41");
expect(new URL(request!.url).searchParams.get("after")).toBe("41");
expect(onReset).toHaveBeenCalledOnce();
expect(onEvents).not.toHaveBeenCalled();
});
it("delivers every event from one stream chunk as an ordered batch", async () => {
const makeEvent = (eventSequence: number) => ({
schema: "workbench-event@1",
event_seq: eventSequence,
event_id: `72000000-0000-4000-8000-${String(eventSequence).padStart(12, "0")}`,
project_id: "324ff7ee-c2d4-5184-a84b-48fb14a2933e",
topic: "operational.task.changed",
aggregate_type: "operational_task",
aggregate_id: `task-${eventSequence}`,
aggregate_revision: 3,
occurred_at: "2026-09-23T08:30:00Z",
metadata: { task_id: `task-${eventSequence}` }
});
const events = [makeEvent(42), makeEvent(43)];
const payload = events.map((event) =>
`id: ${event.event_seq}\nevent: workbench_event\ndata: ${JSON.stringify(event)}\n\n`
).join("");
vi.stubGlobal("fetch", vi.fn(async () => new Response(payload, {
status: 200,
headers: { "Content-Type": "text/event-stream" }
})));
const onEvents = vi.fn();
await expect(consumeWorkbenchEvents({
after: 41,
signal: new AbortController().signal,
onReset: vi.fn(async () => undefined),
onEvents
})).resolves.toBe("closed");
expect(onEvents).toHaveBeenCalledOnce();
expect(onEvents).toHaveBeenCalledWith(events);
});
});
@@ -0,0 +1,127 @@
import { z } from "zod";
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
import { env } from "@/shared/config/env";
import {
parseOperationalTaskNotification,
parseOperationalTaskSummary,
type OperationalTaskNotification,
type OperationalTaskSummary
} from "./operational-task-client";
import { createWorkbenchHeaders, findOwnedWorkbenchProject, getServerError } from "./project-client";
import {
parseWorkbenchRuntimeSnapshot,
type WorkbenchRuntimeSnapshot
} from "./workbench-runtime-client";
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
const snapshotEnvelopeSchema = z.object({
schema: z.literal("workbench-sync-snapshot@1"),
event_cursor: z.number().int().nonnegative(),
runtime: z.unknown(),
tasks: z.array(z.unknown()),
notifications: z.array(z.unknown()),
generated_at: z.string().datetime({ offset: true })
}).strict();
const eventSchema = z.object({
schema: z.literal("workbench-event@1"),
event_seq: z.number().int().positive(),
event_id: z.string().uuid(),
project_id: z.string().uuid(),
topic: z.enum([
"operational.task.changed",
"operational.run.queued",
"operational.run.changed",
"operational.report.published",
"operational.action.changed",
"operational.notification.changed",
"workbench.alert.changed",
"workbench.work_order.changed",
"analysis.run.changed"
]),
aggregate_type: z.string().min(1),
aggregate_id: z.string().min(1),
aggregate_revision: z.number().int().positive(),
occurred_at: z.string().datetime({ offset: true }),
metadata: z.record(z.string(), z.unknown())
}).strict();
export type WorkbenchEvent = z.infer<typeof eventSchema>;
export type WorkbenchSyncSnapshot = {
eventCursor: number;
runtime: WorkbenchRuntimeSnapshot;
tasks: OperationalTaskSummary[];
notifications: OperationalTaskNotification[];
generatedAt: string;
};
export async function fetchWorkbenchSyncSnapshot(
getAccessToken?: AccessTokenProvider,
signal?: AbortSignal
): Promise<WorkbenchSyncSnapshot> {
const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal);
const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/workbench/sync-snapshot`, {
headers: createWorkbenchHeaders(accessToken, project.project_id),
signal
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) throw new Error(getServerError(payload, response.status, "工作台同步快照请求"));
const envelope = snapshotEnvelopeSchema.parse(payload);
return {
eventCursor: envelope.event_cursor,
runtime: parseWorkbenchRuntimeSnapshot(envelope.runtime),
tasks: envelope.tasks.map(parseOperationalTaskSummary),
notifications: envelope.notifications.map(parseOperationalTaskNotification),
generatedAt: envelope.generated_at
};
}
export async function consumeWorkbenchEvents(input: {
after: number;
getAccessToken?: AccessTokenProvider;
onEvents: (events: WorkbenchEvent[]) => void;
onReset: () => Promise<void>;
signal: AbortSignal;
}): Promise<"closed" | "reset"> {
const { project, accessToken } = await findOwnedWorkbenchProject(input.getAccessToken, input.signal);
const headers = createWorkbenchHeaders(accessToken, project.project_id);
headers.set("Accept", "text/event-stream");
headers.set("Last-Event-ID", String(input.after));
const response = await fetch(
`${SERVER_API_BASE_URL}/api/v1/workbench/events?after=${input.after}`,
{ headers, signal: input.signal }
);
if (!response.ok || !response.body) {
const payload: unknown = await response.json().catch(() => null);
throw new Error(getServerError(payload, response.status, "工作台事件连接"));
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!input.signal.aborted) {
const { value, done } = await reader.read();
buffer += decoder.decode(value, { stream: !done });
if (buffer.length > 1_048_576) throw new Error("工作台事件帧超过大小限制");
const events: WorkbenchEvent[] = [];
let boundary = buffer.search(/\r?\n\r?\n/);
while (boundary >= 0) {
const separator = buffer.slice(boundary).match(/^\r?\n\r?\n/)?.[0] ?? "\n\n";
const block = buffer.slice(0, boundary);
buffer = buffer.slice(boundary + separator.length);
const lines = block.split(/\r?\n/);
const eventName = lines.find((line) => line.startsWith("event:"))?.slice(6).trim();
const data = lines.filter((line) => line.startsWith("data:")).map((line) => line.slice(5).trim()).join("\n");
if (eventName === "reset_required") {
await input.onReset();
return "reset";
}
if (eventName === "workbench_event" && data) events.push(eventSchema.parse(JSON.parse(data)));
boundary = buffer.search(/\r?\n\r?\n/);
}
if (events.length > 0) input.onEvents(events);
if (done) return "closed";
}
return "closed";
}