feat(workbench): integrate operational workflows and map state
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
Reference in New Issue
Block a user