Files
app/tauri/src/runtime/coordination/task-state.ts
T

78 lines
3.3 KiB
TypeScript

/**
* 按 `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<string, TaskRecord>();
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<TaskRecord>;
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<TaskStatus>(["running", "waiting", "completed", "failed", "cancelled"]);
function copy(record: TaskRecord): TaskRecord { return { ...record }; }