/** * 按 `operation_id` 聚合 Agent 任务进度的纯状态机。 * * 同一任务的更新只替换最新投影,避免聊天时间线因进度变化无限新增卡片;取消请求也必须 * 经过受限状态跃迁。 */ import type { TaskProgress, TaskStatus } from "@/runtime/protocol/lineup-v1"; export type TaskRecord = TaskProgress & { conversation_id: string; updated_at: string; cancel_requested_at?: string; }; export type TaskTransition = | { disposition: "accepted"; record: TaskRecord } | { disposition: "missing" | "invalid_transition"; record?: TaskRecord }; /** * Platform-neutral task projection. It has no DOM, storage, transport, or * side-effect handler. M1-04 records a user cancellation request locally; * M1-06 later owns durable delivery to the Agent. */ export class TaskStateMachine { private readonly tasks = new Map(); public receive(update: TaskProgress, conversationID: string, updatedAt: string): TaskRecord { const current = this.tasks.get(update.operation_id); const next: TaskRecord = { ...update, conversation_id: conversationID, updated_at: updatedAt, ...(current?.cancel_requested_at && update.status !== "cancelled" ? { cancel_requested_at: current.cancel_requested_at } : {}), }; this.tasks.set(next.operation_id, next); return copy(next); } public requestCancel(operationID: string, updatedAt: string): TaskTransition { const record = this.tasks.get(operationID); if (!record) return { disposition: "missing" }; if (!record.cancellable || isTerminal(record.status) || record.cancel_requested_at) return { disposition: "invalid_transition", record: copy(record) }; record.cancel_requested_at = updatedAt; record.updated_at = updatedAt; return { disposition: "accepted", record: copy(record) }; } public get(operationID: string): TaskRecord | undefined { const record = this.tasks.get(operationID); return record ? copy(record) : undefined; } public snapshot(): readonly TaskRecord[] { return [...this.tasks.values()].map(copy); } public reset(): void { this.tasks.clear(); } public restore(records: readonly TaskRecord[]): void { this.tasks.clear(); for (const record of records) { if (!isRestoreable(record) || this.tasks.has(record.operation_id)) continue; this.tasks.set(record.operation_id, copy(record)); } } } function isTerminal(status: TaskStatus): boolean { return status === "completed" || status === "failed" || status === "cancelled"; } function isRestoreable(value: unknown): value is TaskRecord { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const record = value as Partial; return typeof record.operation_id === "string" && Boolean(record.operation_id) && typeof record.conversation_id === "string" && Boolean(record.conversation_id) && typeof record.title === "string" && typeof record.percent === "number" && Number.isFinite(record.percent) && TASK_STATUS_VALUES.has(record.status as TaskStatus) && typeof record.cancellable === "boolean" && typeof record.updated_at === "string" && !Number.isNaN(Date.parse(record.updated_at)); } const TASK_STATUS_VALUES = new Set(["running", "waiting", "completed", "failed", "cancelled"]); function copy(record: TaskRecord): TaskRecord { return { ...record }; }