| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
|
|
| import { randomUUID } from "crypto";
|
|
|
|
|
|
|
| export type TaskState = "submitted" | "working" | "completed" | "failed" | "cancelled";
|
|
|
| export interface TaskInput {
|
| skill: string;
|
| messages: Array<{ role: string; content: string }>;
|
| metadata?: Record<string, unknown>;
|
| }
|
|
|
| export interface TaskArtifact {
|
| type: "text" | "json" | "error";
|
| content: string;
|
| }
|
|
|
| export interface TaskEvent {
|
| timestamp: string;
|
| state: TaskState;
|
| message?: string;
|
| }
|
|
|
| export interface A2ATask {
|
| id: string;
|
| skill: string;
|
| state: TaskState;
|
| input: TaskInput;
|
| artifacts: TaskArtifact[];
|
| events: TaskEvent[];
|
| metadata: Record<string, unknown>;
|
| createdAt: string;
|
| updatedAt: string;
|
| expiresAt: string;
|
| }
|
|
|
| export interface TaskListFilter {
|
| state?: TaskState;
|
| skill?: string;
|
| limit?: number;
|
| offset?: number;
|
| }
|
|
|
| export interface A2ATaskStats {
|
| counts: Record<TaskState, number>;
|
| total: number;
|
| activeStreams: number;
|
| lastTaskAt: string | null;
|
| }
|
|
|
|
|
|
|
| const VALID_TRANSITIONS: Record<TaskState, TaskState[]> = {
|
| submitted: ["working", "failed", "cancelled"],
|
| working: ["completed", "failed", "cancelled"],
|
| completed: [],
|
| failed: [],
|
| cancelled: [],
|
| };
|
|
|
|
|
|
|
| export class A2ATaskManager {
|
| private tasks = new Map<string, A2ATask>();
|
| private readonly ttlMs: number;
|
| private cleanupInterval: ReturnType<typeof setInterval>;
|
| private activeStreams = 0;
|
|
|
| constructor(ttlMinutes: number = 5) {
|
| this.ttlMs = ttlMinutes * 60 * 1000;
|
| this.cleanupInterval = setInterval(() => this.cleanupExpired(), 60_000);
|
| if (
|
| this.cleanupInterval &&
|
| typeof this.cleanupInterval === "object" &&
|
| "unref" in this.cleanupInterval
|
| ) {
|
| (this.cleanupInterval as { unref?: () => void }).unref?.();
|
| }
|
| }
|
|
|
| createTask(input: TaskInput): A2ATask {
|
| const now = new Date();
|
| const task: A2ATask = {
|
| id: randomUUID(),
|
| skill: input.skill,
|
| state: "submitted",
|
| input,
|
| artifacts: [],
|
| events: [{ timestamp: now.toISOString(), state: "submitted" }],
|
| metadata: input.metadata || {},
|
| createdAt: now.toISOString(),
|
| updatedAt: now.toISOString(),
|
| expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),
|
| };
|
| this.tasks.set(task.id, task);
|
| return task;
|
| }
|
|
|
| getTask(taskId: string): A2ATask | undefined {
|
| const task = this.tasks.get(taskId);
|
| if (task && new Date(task.expiresAt) < new Date()) {
|
| if (task.state === "submitted" || task.state === "working") {
|
| this.updateTask(taskId, "failed", undefined, "Task expired");
|
| }
|
| }
|
| return this.tasks.get(taskId);
|
| }
|
|
|
| updateTask(
|
| taskId: string,
|
| state: TaskState,
|
| artifacts?: TaskArtifact[],
|
| message?: string
|
| ): A2ATask {
|
| const task = this.tasks.get(taskId);
|
| if (!task) throw new Error(`Task ${taskId} not found`);
|
|
|
| const valid = VALID_TRANSITIONS[task.state];
|
| if (!valid.includes(state)) {
|
| throw new Error(`Invalid transition: ${task.state} → ${state}`);
|
| }
|
|
|
| const now = new Date().toISOString();
|
| task.state = state;
|
| task.updatedAt = now;
|
| task.events.push({ timestamp: now, state, message });
|
| if (artifacts) task.artifacts.push(...artifacts);
|
|
|
| return task;
|
| }
|
|
|
| cancelTask(taskId: string): A2ATask {
|
| return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
|
| }
|
|
|
| countTasks(filter?: Pick<TaskListFilter, "state" | "skill">): number {
|
| let tasks = [...this.tasks.values()];
|
| if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
|
| if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
|
| return tasks.length;
|
| }
|
|
|
| listTasks(filter?: TaskListFilter): A2ATask[] {
|
| let tasks = [...this.tasks.values()];
|
| if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
|
| if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
|
| tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
| const offset = Math.max(0, filter?.offset || 0);
|
| const limit =
|
| typeof filter?.limit === "number" && Number.isFinite(filter.limit)
|
| ? Math.max(1, Math.floor(filter.limit))
|
| : 50;
|
| return tasks.slice(offset, offset + limit);
|
| }
|
|
|
| beginStream() {
|
| this.activeStreams += 1;
|
| }
|
|
|
| endStream() {
|
| this.activeStreams = Math.max(0, this.activeStreams - 1);
|
| }
|
|
|
| getStats(): A2ATaskStats {
|
| const counts: Record<TaskState, number> = {
|
| submitted: 0,
|
| working: 0,
|
| completed: 0,
|
| failed: 0,
|
| cancelled: 0,
|
| };
|
|
|
| let lastTaskAt: string | null = null;
|
| for (const task of this.tasks.values()) {
|
| counts[task.state] += 1;
|
| const updatedAt = new Date(task.updatedAt).getTime();
|
| if (!Number.isFinite(updatedAt)) continue;
|
| if (!lastTaskAt || updatedAt > new Date(lastTaskAt).getTime()) {
|
| lastTaskAt = task.updatedAt;
|
| }
|
| }
|
|
|
| return {
|
| counts,
|
| total: this.tasks.size,
|
| activeStreams: this.activeStreams,
|
| lastTaskAt,
|
| };
|
| }
|
|
|
| private cleanupExpired() {
|
| const now = new Date();
|
| for (const [id, task] of this.tasks) {
|
| if (
|
| new Date(task.expiresAt) < now &&
|
| task.state !== "completed" &&
|
| task.state !== "failed" &&
|
| task.state !== "cancelled"
|
| ) {
|
| task.state = "failed";
|
| task.updatedAt = now.toISOString();
|
| task.events.push({ timestamp: now.toISOString(), state: "failed", message: "TTL expired" });
|
| }
|
|
|
| if (
|
| ["completed", "failed", "cancelled"].includes(task.state) &&
|
| now.getTime() - new Date(task.updatedAt).getTime() > this.ttlMs * 2
|
| ) {
|
| this.tasks.delete(id);
|
| }
|
| }
|
| }
|
|
|
| destroy() {
|
| clearInterval(this.cleanupInterval);
|
| }
|
| }
|
|
|
|
|
| const globalForA2A = globalThis as unknown as { _a2aTaskManager?: A2ATaskManager };
|
|
|
| export function getTaskManager(): A2ATaskManager {
|
| if (!globalForA2A._a2aTaskManager) {
|
| globalForA2A._a2aTaskManager = new A2ATaskManager();
|
| }
|
| return globalForA2A._a2aTaskManager;
|
| }
|
|
|