feat(workbench): integrate operational workflows and map state
This commit is contained in:
@@ -0,0 +1,294 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import {
|
||||
acknowledgeOperationalTaskNotification,
|
||||
createOperationalRequestId,
|
||||
createOperationalTask,
|
||||
decideOperationalTaskAction,
|
||||
executeOperationalTaskAction,
|
||||
fetchOperationalTask,
|
||||
fetchOperationalTaskEvidenceSnapshot,
|
||||
fetchOperationalTaskNotifications,
|
||||
reconcileOperationalTaskAction,
|
||||
type OperationalTask,
|
||||
type OperationalTaskDecision,
|
||||
type OperationalTaskNotification
|
||||
} from "../api/operational-task-client";
|
||||
import type { ScheduledConditionRecord, ScheduledConditionRiskLevel, ScheduledConditionStatus } from "../types";
|
||||
import type { WorkbenchSyncState } from "./use-workbench-sync";
|
||||
|
||||
|
||||
export type ManualInvestigationKind = "water_quality_complaint" | "burst_report";
|
||||
export type CreateInvestigationInput = {
|
||||
kind: ManualInvestigationKind;
|
||||
title: string;
|
||||
summary: string;
|
||||
detail: string;
|
||||
occurredAt: string;
|
||||
priority?: OperationalTask["priority"];
|
||||
source?: OperationalTask["source"];
|
||||
triggerKind?: "event" | "manual";
|
||||
};
|
||||
|
||||
export function useOperationalTasks(
|
||||
getAccessToken: AccessTokenProvider | undefined,
|
||||
sync: WorkbenchSyncState
|
||||
) {
|
||||
const syncEvents = sync.events;
|
||||
const acknowledgeEventsThrough = sync.acknowledgeEventsThrough;
|
||||
const [tasks, setTasks] = useState<OperationalTask[]>([]);
|
||||
const [notifications, setNotifications] = useState<OperationalTaskNotification[]>([]);
|
||||
const [available, setAvailable] = useState(false);
|
||||
const activatedRef = useRef(false);
|
||||
const tasksRef = useRef<OperationalTask[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastSyncedAt, setLastSyncedAt] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshGeneration, setRefreshGeneration] = useState(0);
|
||||
const forceRefreshRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
forceRefreshRef.current = true;
|
||||
setRefreshGeneration((current) => current + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const load = async () => {
|
||||
const summaries = sync.snapshot?.tasks ?? [];
|
||||
try {
|
||||
const current = new Map(tasksRef.current.map((task) => [task.task_id, task]));
|
||||
const changed = forceRefreshRef.current
|
||||
? summaries
|
||||
: summaries.filter((summary) => current.get(summary.task_id)?.revision !== summary.revision);
|
||||
const details = await Promise.all(changed.map((task) =>
|
||||
fetchOperationalTask(task.task_id, getAccessToken, controller.signal)
|
||||
));
|
||||
if (controller.signal.aborted) return;
|
||||
const detailMap = new Map(details.map((task) => [task.task_id, task]));
|
||||
const nextTasks = summaries.flatMap((summary) => {
|
||||
const detail = detailMap.get(summary.task_id) ?? current.get(summary.task_id);
|
||||
return detail ? [detail] : [];
|
||||
});
|
||||
tasksRef.current = nextTasks;
|
||||
forceRefreshRef.current = false;
|
||||
setTasks(nextTasks);
|
||||
setNotifications((sync.snapshot?.notifications ?? []).filter((item) => item.status === "unread"));
|
||||
setAvailable(true);
|
||||
activatedRef.current = true;
|
||||
setError(null);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
} catch (nextError) {
|
||||
if (controller.signal.aborted) return;
|
||||
if (!activatedRef.current) setAvailable(false);
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务服务暂时不可用。");
|
||||
} finally {
|
||||
if (!controller.signal.aborted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (sync.snapshot) void load();
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
}, [getAccessToken, refreshGeneration, sync.snapshot]);
|
||||
|
||||
useEffect(() => {
|
||||
const events = syncEvents;
|
||||
if (events.length === 0) return;
|
||||
const controller = new AbortController();
|
||||
const lastSequence = events.at(-1)!.event_seq;
|
||||
const taskIds = new Set<string>();
|
||||
let notificationsChanged = false;
|
||||
for (const event of events) {
|
||||
if (event.topic === "operational.notification.changed") notificationsChanged = true;
|
||||
if (!event.topic.startsWith("operational.") || event.topic === "operational.notification.changed") continue;
|
||||
const taskId = typeof event.metadata.task_id === "string"
|
||||
? event.metadata.task_id
|
||||
: event.aggregate_type === "operational_task" ? event.aggregate_id : null;
|
||||
if (taskId) taskIds.add(taskId);
|
||||
}
|
||||
void Promise.all([
|
||||
notificationsChanged
|
||||
? fetchOperationalTaskNotifications(getAccessToken, controller.signal)
|
||||
: Promise.resolve(null),
|
||||
Promise.all([...taskIds].map((taskId) =>
|
||||
fetchOperationalTask(taskId, getAccessToken, controller.signal)
|
||||
))
|
||||
]).then(([nextNotifications, changedTasks]) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (nextNotifications) setNotifications(nextNotifications);
|
||||
if (changedTasks.length > 0) {
|
||||
const changed = new Map(changedTasks.map((task) => [task.task_id, task]));
|
||||
tasksRef.current = [
|
||||
...changedTasks,
|
||||
...tasksRef.current.filter((task) => !changed.has(task.task_id))
|
||||
];
|
||||
setTasks(tasksRef.current);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
}
|
||||
acknowledgeEventsThrough(lastSequence);
|
||||
}).catch((nextError) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setError(nextError instanceof Error ? nextError.message : "工况任务增量刷新失败。");
|
||||
}
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [acknowledgeEventsThrough, getAccessToken, syncEvents]);
|
||||
|
||||
const conditions = useMemo(() => tasks.map(toOperationalTaskCondition), [tasks]);
|
||||
|
||||
const createInvestigation = useCallback(async (input: CreateInvestigationInput) => {
|
||||
const task = await createOperationalTask({
|
||||
request_id: createOperationalRequestId(),
|
||||
task_type: "event_investigation",
|
||||
workflow: input.kind,
|
||||
title: input.title,
|
||||
summary: input.summary,
|
||||
priority: input.priority ?? (input.kind === "burst_report" ? "urgent" : "normal"),
|
||||
source: input.source ?? { kind: "operator", external_id: null, external_revision: null, event_kind: input.kind },
|
||||
trigger: {
|
||||
kind: input.triggerKind ?? "manual",
|
||||
occurred_at: input.occurredAt,
|
||||
detail: input.detail
|
||||
}
|
||||
}, getAccessToken);
|
||||
tasksRef.current = [task, ...tasksRef.current.filter((item) => item.task_id !== task.task_id)];
|
||||
setTasks(tasksRef.current);
|
||||
activatedRef.current = true;
|
||||
setAvailable(true);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setLastSyncedAt(new Date().toISOString());
|
||||
return task;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const decideAction = useCallback(async (
|
||||
taskId: string,
|
||||
actionId: string,
|
||||
expectedVersion: number,
|
||||
decision: OperationalTaskDecision,
|
||||
comment?: string
|
||||
) => {
|
||||
const action = await decideOperationalTaskAction(taskId, actionId, {
|
||||
request_id: createOperationalRequestId(),
|
||||
expected_version: expectedVersion,
|
||||
decision,
|
||||
comment
|
||||
}, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
||||
...item,
|
||||
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
||||
} : item));
|
||||
return action;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const executeAction = useCallback(async (
|
||||
taskId: string,
|
||||
actionId: string,
|
||||
expectedVersion: number
|
||||
) => {
|
||||
const action = await executeOperationalTaskAction(taskId, actionId, {
|
||||
request_id: createOperationalRequestId(),
|
||||
expected_version: expectedVersion
|
||||
}, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
||||
...item,
|
||||
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
||||
} : item));
|
||||
return action;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const reconcileAction = useCallback(async (taskId: string, actionId: string, expectedVersion: number) => {
|
||||
const action = await reconcileOperationalTaskAction(taskId, actionId, {
|
||||
request_id: createOperationalRequestId(),
|
||||
expected_version: expectedVersion
|
||||
}, getAccessToken);
|
||||
setTasks((current) => current.map((item) => item.task_id === taskId ? {
|
||||
...item,
|
||||
actions: item.actions.map((currentAction) => currentAction.action_id === action.action_id ? action : currentAction)
|
||||
} : item));
|
||||
return action;
|
||||
}, [getAccessToken]);
|
||||
|
||||
const acknowledgeNotification = useCallback(async (notificationId: string, version: number) => {
|
||||
await acknowledgeOperationalTaskNotification(
|
||||
notificationId,
|
||||
version,
|
||||
createOperationalRequestId(),
|
||||
getAccessToken
|
||||
);
|
||||
setNotifications((current) => current.filter((item) => item.notification_id !== notificationId));
|
||||
}, [getAccessToken]);
|
||||
|
||||
const loadEvidenceSnapshot = useCallback((taskId: string, evidenceId: string) =>
|
||||
fetchOperationalTaskEvidenceSnapshot(taskId, evidenceId, getAccessToken), [getAccessToken]);
|
||||
|
||||
return {
|
||||
available,
|
||||
error: error ?? sync.error,
|
||||
syncStatus: sync.status,
|
||||
lastSyncedAt,
|
||||
loading,
|
||||
tasks,
|
||||
conditions,
|
||||
notifications,
|
||||
refresh,
|
||||
createInvestigation,
|
||||
decideAction,
|
||||
executeAction,
|
||||
reconcileAction,
|
||||
acknowledgeNotification,
|
||||
loadEvidenceSnapshot
|
||||
};
|
||||
}
|
||||
|
||||
export function toOperationalTaskCondition(task: OperationalTask): ScheduledConditionRecord {
|
||||
return {
|
||||
id: operationalTaskConditionId(task.task_id),
|
||||
kind: "condition",
|
||||
taskId: "operational-task",
|
||||
dataSource: "operational_tasks",
|
||||
sessionId: task.active_run?.agent_session_id ?? task.agent_session_id ?? "",
|
||||
title: task.title,
|
||||
summary: task.summary,
|
||||
status: toConditionStatus(task),
|
||||
riskLevel: toRiskLevel(task),
|
||||
scheduledAt: task.scheduled_for ?? task.trigger.occurred_at,
|
||||
updatedAt: Date.parse(task.updated_at),
|
||||
detail: task.task_type === "external_work_order"
|
||||
? task.work_order_snapshot?.summary ?? task.summary
|
||||
: task.trigger.detail,
|
||||
recommendation: task.latest_report?.report.current_conclusion || undefined,
|
||||
evidence: task.evidence.map((item) => item.summary),
|
||||
modelName: "TJWater operational task",
|
||||
operationalTask: task
|
||||
};
|
||||
}
|
||||
|
||||
export function operationalTaskConditionId(taskId: string) {
|
||||
return `operational-task:${taskId}`;
|
||||
}
|
||||
|
||||
function toConditionStatus(task: OperationalTask): ScheduledConditionStatus {
|
||||
const status = task.lifecycle_status;
|
||||
if (status === "completed") return "completed";
|
||||
if (status === "failed") return "error";
|
||||
if (status === "cancelled" || status === "waiting_human") return "warning";
|
||||
if (task.task_type === "external_work_order") {
|
||||
const externalStatus = task.work_order_snapshot?.status;
|
||||
if (externalStatus === "completed") return "completed";
|
||||
if (externalStatus === "cancelled" || externalStatus === "awaiting_reply") return "warning";
|
||||
if (externalStatus === "in_progress") return "running";
|
||||
return "pending";
|
||||
}
|
||||
return task.active_run?.status === "queued" ? "pending" : "running";
|
||||
}
|
||||
|
||||
function toRiskLevel(task: OperationalTask): ScheduledConditionRiskLevel {
|
||||
const { priority, lifecycle_status: status } = task;
|
||||
if (status === "failed" || priority === "urgent") return "critical";
|
||||
if (status === "waiting_human") return "attention";
|
||||
return "normal";
|
||||
}
|
||||
Reference in New Issue
Block a user