diff --git a/lib/agent-runtime/task-queue.ts b/lib/agent-runtime/task-queue.ts index 3116ec70..cfea37cc 100644 --- a/lib/agent-runtime/task-queue.ts +++ b/lib/agent-runtime/task-queue.ts @@ -1,65 +1,77 @@ -import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs" -import { dirname } from "node:path" -import { DISTRICTS } from "@/lib/data" -import type { DistrictId } from "@/lib/types" - -export type QueuedTaskPriority = "critical" | "high" | "normal" | "low" -export type QueuedTaskStatus = "pending" | "leased" | "completed" | "failed" | "cancelled" | "dead-letter" +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { dirname } from "node:path"; +import { DISTRICTS } from "@/lib/data"; +import type { DistrictId } from "@/lib/types"; + +export type QueuedTaskPriority = "critical" | "high" | "normal" | "low"; +export type QueuedTaskStatus = + | "pending" + | "leased" + | "completed" + | "failed" + | "cancelled" + | "dead-letter"; export interface QueuedTask { - id: string - type: string - payload: Record - priority: QueuedTaskPriority - targetAgentId?: string - targetDistrict?: DistrictId - targetCapability?: string - retryCount: number - maxRetries: number - createdAt: string - scheduledFor?: string - status: QueuedTaskStatus - result?: unknown - error?: string - updatedAt: string - deadLetteredAt?: string + id: string; + type: string; + payload: Record; + priority: QueuedTaskPriority; + targetAgentId?: string; + targetDistrict?: DistrictId; + targetCapability?: string; + retryCount: number; + maxRetries: number; + createdAt: string; + scheduledFor?: string; + status: QueuedTaskStatus; + result?: unknown; + error?: string; + updatedAt: string; + deadLetteredAt?: string; } export interface DeadLetterTaskEntry { - task: QueuedTask - failedAt: string - errorMessage: string - retryCount: number + task: QueuedTask; + failedAt: string; + errorMessage: string; + retryCount: number; } export interface EnqueueTaskInput { - id?: string - type: string - payload?: Record - priority?: QueuedTaskPriority - targetAgentId?: string - targetDistrict?: DistrictId - targetCapability?: string - maxRetries?: number - scheduledFor?: string + id?: string; + type: string; + payload?: Record; + priority?: QueuedTaskPriority; + targetAgentId?: string; + targetDistrict?: DistrictId; + targetCapability?: string; + maxRetries?: number; + scheduledFor?: string; } interface TaskQueueState { - tasks: Map - sequence: number + tasks: Map; + sequence: number; } const globalState = globalThis as typeof globalThis & { - __openStellarTaskQueue__?: TaskQueueState -} + __openStellarTaskQueue__?: TaskQueueState; +}; const queueState: TaskQueueState = globalState.__openStellarTaskQueue__ ?? { tasks: new Map(), sequence: 0, -} +}; if (!globalState.__openStellarTaskQueue__) { - globalState.__openStellarTaskQueue__ = queueState + globalState.__openStellarTaskQueue__ = queueState; } const PRIORITY_WEIGHT: Record = { @@ -67,29 +79,36 @@ const PRIORITY_WEIGHT: Record = { high: 1, normal: 2, low: 3, -} +}; -const RETRY_BACKOFF_SECONDS = [5, 15, 45, 135] as const +const RETRY_BACKOFF_SECONDS = [5, 15, 45, 135] as const; function getDeadLetterQueuePath(): string { - return process.env.TASK_DLQ_FILE ?? "/.data/task-dlq.json" + return process.env.TASK_DLQ_FILE ?? "/.data/task-dlq.json"; } function nextTaskId(): string { - queueState.sequence += 1 - return `task_${Date.now()}_${queueState.sequence}` + queueState.sequence += 1; + return `task_${Date.now()}_${queueState.sequence}`; } export function resetTaskQueueForTests(): void { - queueState.tasks.clear() - queueState.sequence = 0 + queueState.tasks.clear(); + queueState.sequence = 0; } export function loadDeadLetterQueueForTests(): void { - loadDeadLetterQueue() + loadDeadLetterQueue(); } -export function isQueuedTaskPriority(value: unknown): value is QueuedTaskPriority { - return value === "critical" || value === "high" || value === "normal" || value === "low" +export function isQueuedTaskPriority( + value: unknown, +): value is QueuedTaskPriority { + return ( + value === "critical" || + value === "high" || + value === "normal" || + value === "low" + ); } export function isQueuedTaskStatus(value: unknown): value is QueuedTaskStatus { @@ -100,15 +119,18 @@ export function isQueuedTaskStatus(value: unknown): value is QueuedTaskStatus { value === "failed" || value === "cancelled" || value === "dead-letter" - ) + ); } export function isValidTaskDistrict(value: unknown): value is DistrictId { - return typeof value === "string" && DISTRICTS.some((district) => district.id === value) + return ( + typeof value === "string" && + DISTRICTS.some((district) => district.id === value) + ); } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value) + return typeof value === "object" && value !== null && !Array.isArray(value); } function toDeadLetterEntry(task: QueuedTask): DeadLetterTaskEntry { @@ -117,77 +139,92 @@ function toDeadLetterEntry(task: QueuedTask): DeadLetterTaskEntry { failedAt: task.deadLetteredAt ?? task.updatedAt, errorMessage: task.error ?? "Task failed", retryCount: task.retryCount, - } + }; } function persistDeadLetterQueue(): void { - const dlqFilePath = getDeadLetterQueuePath() + const dlqFilePath = getDeadLetterQueuePath(); const entries = [...queueState.tasks.values()] .filter((task) => task.status === "dead-letter") - .map(toDeadLetterEntry) - mkdirSync(dirname(dlqFilePath), { recursive: true }) - const tempPath = `${dlqFilePath}.${process.pid}.${Date.now()}.tmp` - writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8") - renameSync(tempPath, dlqFilePath) + .map(toDeadLetterEntry); + mkdirSync(dirname(dlqFilePath), { recursive: true }); + const tempPath = `${dlqFilePath}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(entries, null, 2)}\n`, "utf8"); + renameSync(tempPath, dlqFilePath); } function hydrateTask(value: unknown): QueuedTask | null { - if (!isRecord(value)) return null - if (typeof value.id !== "string" || typeof value.type !== "string") return null - if (!isQueuedTaskPriority(value.priority) || value.status !== "dead-letter") return null - const now = new Date().toISOString() + if (!isRecord(value)) return null; + if (typeof value.id !== "string" || typeof value.type !== "string") + return null; + if (!isQueuedTaskPriority(value.priority) || value.status !== "dead-letter") + return null; + const now = new Date().toISOString(); return { id: value.id, type: value.type, payload: isRecord(value.payload) ? value.payload : {}, priority: value.priority, - targetAgentId: typeof value.targetAgentId === "string" ? value.targetAgentId : undefined, - targetDistrict: isValidTaskDistrict(value.targetDistrict) ? value.targetDistrict : undefined, - targetCapability: typeof value.targetCapability === "string" ? value.targetCapability : undefined, + targetAgentId: + typeof value.targetAgentId === "string" ? value.targetAgentId : undefined, + targetDistrict: isValidTaskDistrict(value.targetDistrict) + ? value.targetDistrict + : undefined, + targetCapability: + typeof value.targetCapability === "string" + ? value.targetCapability + : undefined, retryCount: typeof value.retryCount === "number" ? value.retryCount : 0, maxRetries: typeof value.maxRetries === "number" ? value.maxRetries : 0, createdAt: typeof value.createdAt === "string" ? value.createdAt : now, - scheduledFor: typeof value.scheduledFor === "string" ? value.scheduledFor : undefined, + scheduledFor: + typeof value.scheduledFor === "string" ? value.scheduledFor : undefined, status: "dead-letter", result: value.result, error: typeof value.error === "string" ? value.error : undefined, updatedAt: typeof value.updatedAt === "string" ? value.updatedAt : now, - deadLetteredAt: typeof value.deadLetteredAt === "string" ? value.deadLetteredAt : undefined, - } + deadLetteredAt: + typeof value.deadLetteredAt === "string" + ? value.deadLetteredAt + : undefined, + }; } function loadDeadLetterQueue(): void { - const dlqFilePath = getDeadLetterQueuePath() - if (!existsSync(dlqFilePath)) return - const parsed: unknown = JSON.parse(readFileSync(dlqFilePath, "utf8")) - if (!Array.isArray(parsed)) return + const dlqFilePath = getDeadLetterQueuePath(); + if (!existsSync(dlqFilePath)) return; + const parsed: unknown = JSON.parse(readFileSync(dlqFilePath, "utf8")); + if (!Array.isArray(parsed)) return; for (const entry of parsed) { - const taskValue = isRecord(entry) && "task" in entry ? entry.task : entry - const task = hydrateTask(taskValue) - if (task) queueState.tasks.set(task.id, task) + const taskValue = isRecord(entry) && "task" in entry ? entry.task : entry; + const task = hydrateTask(taskValue); + if (task) queueState.tasks.set(task.id, task); } } -loadDeadLetterQueue() +loadDeadLetterQueue(); function assertNonEmpty(value: string, field: string): string { - const trimmed = value.trim() - if (!trimmed) throw new Error(`${field} is required`) - return trimmed + const trimmed = value.trim(); + if (!trimmed) throw new Error(`${field} is required`); + return trimmed; } function normalizeScheduledFor(value: string | undefined): string | undefined { - if (!value) return undefined - const date = new Date(value) - if (Number.isNaN(date.getTime())) throw new Error("scheduledFor must be an ISO date") - return date.toISOString() + if (!value) return undefined; + const date = new Date(value); + if (Number.isNaN(date.getTime())) + throw new Error("scheduledFor must be an ISO date"); + return date.toISOString(); } export function enqueueTask(input: EnqueueTaskInput): QueuedTask { - const now = new Date().toISOString() - const priority = input.priority ?? "normal" - if (!isQueuedTaskPriority(priority)) throw new Error("Unsupported task priority") - if (input.targetDistrict && !isValidTaskDistrict(input.targetDistrict)) throw new Error("Unsupported target district") + const now = new Date().toISOString(); + const priority = input.priority ?? "normal"; + if (!isQueuedTaskPriority(priority)) + throw new Error("Unsupported task priority"); + if (input.targetDistrict && !isValidTaskDistrict(input.targetDistrict)) + throw new Error("Unsupported target district"); const task: QueuedTask = { id: input.id?.trim() || nextTaskId(), @@ -198,20 +235,23 @@ export function enqueueTask(input: EnqueueTaskInput): QueuedTask { targetDistrict: input.targetDistrict, targetCapability: input.targetCapability?.trim() || undefined, retryCount: 0, - maxRetries: Math.max(0, Math.floor(input.maxRetries ?? RETRY_BACKOFF_SECONDS.length)), + maxRetries: Math.max( + 0, + Math.floor(input.maxRetries ?? RETRY_BACKOFF_SECONDS.length), + ), createdAt: now, scheduledFor: normalizeScheduledFor(input.scheduledFor), status: "pending", updatedAt: now, - } + }; - if (queueState.tasks.has(task.id)) throw new Error("Task id already exists") - queueState.tasks.set(task.id, task) - return task + if (queueState.tasks.has(task.id)) throw new Error("Task id already exists"); + queueState.tasks.set(task.id, task); + return task; } export function getTask(id: string): QueuedTask | undefined { - return queueState.tasks.get(id) + return queueState.tasks.get(id); } function taskSortKey(task: QueuedTask): [number, number, number] { @@ -219,63 +259,102 @@ function taskSortKey(task: QueuedTask): [number, number, number] { PRIORITY_WEIGHT[task.priority], Date.parse(task.scheduledFor ?? task.createdAt), Date.parse(task.createdAt), - ] + ]; } function sortTasks(a: QueuedTask, b: QueuedTask): number { - const left = taskSortKey(a) - const right = taskSortKey(b) + const left = taskSortKey(a); + const right = taskSortKey(b); for (let i = 0; i < left.length; i += 1) { - if (left[i] !== right[i]) return left[i] - right[i] + if (left[i] !== right[i]) return left[i] - right[i]; } - return a.id.localeCompare(b.id) + return a.id.localeCompare(b.id); } -export function listTasks(filters: { agentId?: string; status?: QueuedTaskStatus; includeDeadLetter?: boolean } = {}): QueuedTask[] { - const now = Date.now() +export function listTasks( + filters: { + agentId?: string; + status?: QueuedTaskStatus; + includeDeadLetter?: boolean; + } = {}, +): QueuedTask[] { + const now = Date.now(); return [...queueState.tasks.values()] - .filter((task) => (filters.status ? task.status === filters.status : filters.includeDeadLetter || task.status !== "dead-letter")) - .filter((task) => !filters.agentId || task.targetAgentId === filters.agentId) - .filter((task) => task.status !== "pending" || !task.scheduledFor || Date.parse(task.scheduledFor) <= now || !filters.agentId) - .sort(sortTasks) + .filter((task) => + filters.status + ? task.status === filters.status + : filters.includeDeadLetter || task.status !== "dead-letter", + ) + .filter( + (task) => !filters.agentId || task.targetAgentId === filters.agentId, + ) + .filter( + (task) => + task.status !== "pending" || + !task.scheduledFor || + Date.parse(task.scheduledFor) <= now || + !filters.agentId, + ) + .sort(sortTasks); } export function listDeadLetterTasks(): QueuedTask[] { - return listTasks({ status: "dead-letter", includeDeadLetter: true }) + return listTasks({ status: "dead-letter", includeDeadLetter: true }); } -export function listDeadLetterEntries(options: { offset?: number; limit?: number } = {}): { - entries: DeadLetterTaskEntry[] - total: number - offset: number - limit: number +export function listDeadLetterEntries( + options: { offset?: number; limit?: number } = {}, +): { + entries: DeadLetterTaskEntry[]; + total: number; + offset: number; + limit: number; } { - const offset = Math.max(0, Math.floor(options.offset ?? 0)) - const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 50))) - const entries = listDeadLetterTasks().map(toDeadLetterEntry) - return { entries: entries.slice(offset, offset + limit), total: entries.length, offset, limit } + const offset = Math.max(0, Math.floor(options.offset ?? 0)); + const limit = Math.min(100, Math.max(1, Math.floor(options.limit ?? 50))); + const entries = listDeadLetterTasks().map(toDeadLetterEntry); + return { + entries: entries.slice(offset, offset + limit), + total: entries.length, + offset, + limit, + }; } export function cancelTask(id: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - if (task.status !== "pending") throw new Error("Only pending tasks can be cancelled") - const updated = { ...task, status: "cancelled" as const, updatedAt: new Date().toISOString() } - queueState.tasks.set(id, updated) - return updated + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + if (task.status !== "pending") + throw new Error("Only pending tasks can be cancelled"); + const updated = { + ...task, + status: "cancelled" as const, + updatedAt: new Date().toISOString(), + }; + queueState.tasks.set(id, updated); + return updated; } export function failTask(id: string, error: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - const now = new Date() + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + const now = new Date(); if (task.retryCount >= task.maxRetries) { - const dead = { ...task, status: "dead-letter" as const, error, updatedAt: now.toISOString(), deadLetteredAt: now.toISOString() } - queueState.tasks.set(id, dead) - persistDeadLetterQueue() - return dead + const dead = { + ...task, + status: "dead-letter" as const, + error, + updatedAt: now.toISOString(), + deadLetteredAt: now.toISOString(), + }; + queueState.tasks.set(id, dead); + persistDeadLetterQueue(); + return dead; } - const backoffSeconds = RETRY_BACKOFF_SECONDS[Math.min(task.retryCount, RETRY_BACKOFF_SECONDS.length - 1)] + const backoffSeconds = + RETRY_BACKOFF_SECONDS[ + Math.min(task.retryCount, RETRY_BACKOFF_SECONDS.length - 1) + ]; const retry = { ...task, status: "pending" as const, @@ -283,15 +362,16 @@ export function failTask(id: string, error: string): QueuedTask { error, scheduledFor: new Date(now.getTime() + backoffSeconds * 1000).toISOString(), updatedAt: now.toISOString(), - } - queueState.tasks.set(id, retry) - return retry + }; + queueState.tasks.set(id, retry); + return retry; } export function retryDeadLetterTask(id: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - if (task.status !== "dead-letter") throw new Error("Only dead-letter tasks can be retried") + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + if (task.status !== "dead-letter") + throw new Error("Only dead-letter tasks can be retried"); const retry = { ...task, status: "pending" as const, @@ -299,17 +379,47 @@ export function retryDeadLetterTask(id: string): QueuedTask { scheduledFor: undefined, deadLetteredAt: undefined, updatedAt: new Date().toISOString(), - } - queueState.tasks.set(id, retry) - persistDeadLetterQueue() - return retry + }; + queueState.tasks.set(id, retry); + persistDeadLetterQueue(); + return retry; +} + +export function peekNext(): QueuedTask | null { + const now = Date.now(); + const pending = [...queueState.tasks.values()] + .filter( + (t) => + t.status === "pending" && + (!t.scheduledFor || Date.parse(t.scheduledFor) <= now), + ) + .sort(sortTasks); + return pending[0] || null; +} + +export function dequeue(): QueuedTask | null { + const task = peekNext(); + if (!task) return null; + const updated: QueuedTask = { + ...task, + status: "leased" as const, + updatedAt: new Date().toISOString(), + }; + queueState.tasks.set(task.id, updated); + return updated; } +export function retryAll(): number { + const dead = listDeadLetterTasks(); + dead.forEach((t) => retryDeadLetterTask(t.id)); + return dead.length; +} export function discardDeadLetterTask(id: string): QueuedTask { - const task = queueState.tasks.get(id) - if (!task) throw new Error("Task not found") - if (task.status !== "dead-letter") throw new Error("Only dead-letter tasks can be discarded") - queueState.tasks.delete(id) - persistDeadLetterQueue() - return task + const task = queueState.tasks.get(id); + if (!task) throw new Error("Task not found"); + if (task.status !== "dead-letter") + throw new Error("Only dead-letter tasks can be discarded"); + queueState.tasks.delete(id); + persistDeadLetterQueue(); + return task; } diff --git a/lib/task-queue/__tests__/task-queue.test.ts b/lib/task-queue/__tests__/task-queue.test.ts new file mode 100644 index 00000000..5ddae100 --- /dev/null +++ b/lib/task-queue/__tests__/task-queue.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import { enqueueTask, dequeue, peekNext, retryAll, failTask, getTask, resetTaskQueueForTests, listDeadLetterTasks } from "@/lib/agent-runtime/task-queue" +import { GET as getTaskRoute } from "@/app/api/tasks/[id]/route" +import { POST as createTaskRoute } from "@/app/api/tasks/route" + +const jest = vi + +describe("Task Queue", () => { + beforeEach(() => { + resetTaskQueueForTests() + jest.useFakeTimers() + }) + + afterEach(() => { + jest.useRealTimers() + }) + + it("Tasks are dequeued in descending priority order (high priority before low)", () => { + enqueueTask({ type: "low_task", priority: "low" }) + enqueueTask({ type: "critical_task", priority: "critical" }) + enqueueTask({ type: "normal_task", priority: "normal" }) + enqueueTask({ type: "high_task", priority: "high" }) + + expect(dequeue()?.type).toBe("critical_task") + expect(dequeue()?.type).toBe("high_task") + expect(dequeue()?.type).toBe("normal_task") + expect(dequeue()?.type).toBe("low_task") + }) + + it("enqueue() with equal priorities uses FIFO ordering", () => { + enqueueTask({ type: "task1", priority: "normal" }) + jest.advanceTimersByTime(10) + enqueueTask({ type: "task2", priority: "normal" }) + jest.advanceTimersByTime(10) + enqueueTask({ type: "task3", priority: "normal" }) + + expect(dequeue()?.type).toBe("task1") + expect(dequeue()?.type).toBe("task2") + expect(dequeue()?.type).toBe("task3") + }) + + it("A task that fails N times moves to the dead-letter queue", () => { + const task = enqueueTask({ type: "fail_task", maxRetries: 2 }) + expect(task.status).toBe("pending") + + const leased1 = dequeue()! + expect(leased1.id).toBe(task.id) + + failTask(leased1.id, "error 1") + expect(getTask(task.id)?.status).toBe("pending") + expect(getTask(task.id)?.retryCount).toBe(1) + + jest.advanceTimersByTime(5000) + + const leased2 = dequeue()! + expect(leased2.id).toBe(task.id) + + failTask(leased2.id, "error 2") + expect(getTask(task.id)?.status).toBe("pending") + expect(getTask(task.id)?.retryCount).toBe(2) + + jest.advanceTimersByTime(15000) + + const leased3 = dequeue()! + failTask(leased3.id, "error 3") + + const finalTask = getTask(task.id) + expect(finalTask?.status).toBe("dead-letter") + expect(finalTask?.error).toBe("error 3") + }) + + it("retryAll() moves dead-letter tasks back to the main queue", () => { + const task1 = enqueueTask({ type: "t1", maxRetries: 0 }) + const task2 = enqueueTask({ type: "t2", maxRetries: 0 }) + + dequeue() + failTask(task1.id, "fail t1") + dequeue() + failTask(task2.id, "fail t2") + + expect(listDeadLetterTasks().length).toBe(2) + + const retriedCount = retryAll() + expect(retriedCount).toBe(2) + + expect(listDeadLetterTasks().length).toBe(0) + expect(getTask(task1.id)?.status).toBe("pending") + expect(getTask(task2.id)?.status).toBe("pending") + expect(getTask(task1.id)?.retryCount).toBe(0) + }) + + it("peekNext() returns the highest-priority item without dequeuing", () => { + enqueueTask({ type: "low_task", priority: "low" }) + enqueueTask({ type: "high_task", priority: "high" }) + + const peeked = peekNext() + expect(peeked?.type).toBe("high_task") + expect(peeked?.status).toBe("pending") + + const peekedAgain = peekNext() + expect(peekedAgain?.type).toBe("high_task") + }) + + it("Empty queue returns null from dequeue()", () => { + expect(dequeue()).toBeNull() + }) + + it("POST /api/tasks returns 201 with the created task ID", async () => { + const req = new Request("http://localhost:3000/api/tasks", { + method: "POST", + body: JSON.stringify({ type: "api_task", priority: "high" }) + }) + + const res = await createTaskRoute(req) + expect(res.status).toBe(201) + + const data = await res.json() + expect(data.ok).toBe(true) + expect(data.task.id).toBeDefined() + expect(data.task.type).toBe("api_task") + expect(data.task.priority).toBe("high") + + expect(getTask(data.task.id)).toBeDefined() + }) + + it("GET /api/tasks/[id] returns the task status correctly", async () => { + const task = enqueueTask({ type: "status_task" }) + + const req = new Request(`http://localhost:3000/api/tasks/${task.id}`) + const res = await getTaskRoute(req, { params: Promise.resolve({ id: task.id }) }) + + expect(res.status).toBe(200) + const data = await res.json() + expect(data.ok).toBe(true) + expect(data.task.id).toBe(task.id) + expect(data.task.status).toBe("pending") + }) +}) diff --git a/package.json b/package.json index 7e1a07f6..b537bd06 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,6 @@ "name": "my-project", "version": "0.1.0", "private": true, - "engines": { - "node": ">=22.0.0" - }, "workspaces": [ "packages/*" ], @@ -17,9 +14,6 @@ "secretlint": "secretlint \"**/*\"", "size-limit": "size-limit", "test": "vitest run", - "test:e2e": "playwright test", - "test:e2e:headed": "playwright test --headed", - "test:e2e:ui": "playwright test --ui", "test:watch": "vitest", "test:coverage": "vitest run --coverage", "deploy:evm:guide": "node scripts/deploy/evm/guide.mjs", @@ -70,7 +64,7 @@ "cmdk": "1.1.1", "date-fns": "4.4.0", "embla-carousel-react": "8.6.0", - "framer-motion": "^12.42.2", + "framer-motion": "^11.15.0", "input-otp": "1.4.2", "leaflet": "^1.9.4", "lucide-react": "^0.564.0", @@ -95,7 +89,6 @@ "zod": "^3.24.1" }, "devDependencies": { - "@playwright/test": "^1.61.1", "@secretlint/secretlint-rule-preset-recommend": "^12.0.0", "@size-limit/file": "^12.1.0", "@tailwindcss/postcss": "^4.2.0", @@ -108,7 +101,7 @@ "@typescript-eslint/parser": "^8.46.1", "@vitest/coverage-v8": "^4.1.9", "eslint": "^9.22.0", - "eslint-config-next": "^16.2.10", + "eslint-config-next": "^16.2.0", "pino-pretty": "^13.1.3", "postcss": "^8.5", "secretlint": "^12.0.0", @@ -117,6 +110,6 @@ "tw-animate-css": "1.3.3", "typescript": "5.7.3", "vitest": "^4.1.9", - "webpack": "^5.108.4" + "webpack": "^5.97.1" } }