Spaces:
Sleeping
Sleeping
| /** | |
| * taskStore.ts — Zustand store per task execution state | |
| * | |
| * Traccia task agent con: id, status, steps, logs, artifacts, rollback. | |
| * Persistente su Dexie/IndexedDB (FASE 2.1 — sopravvive a reload della tab). | |
| * | |
| * Checklist 7.1 — unified state architecture (taskStore) | |
| * Checklist 4.2 — structured task execution | |
| * Checklist 2.1 — Task state persistent su Dexie ✅ | |
| * FASE 3.3 — Queue scheduler: QUEUED→RUNNING via DOM event; retry con backoff ✅ | |
| * Replit-like action tracking: TaskAction[] ✅ | |
| * | |
| * S36 — Task Persistence Layer: | |
| * + TaskStep type: per-step status con label/status/output | |
| * + steps: TaskStep[] su Task (persiste su Dexie come steps: unknown[]) | |
| * + addStep / completeStep / failStep actions | |
| * + Hydration intelligente: task < 30min → RESUME candidato; > 30min → CANCELLED | |
| * + subscribeToEventRuntime(): bridge EventRuntime → taskStore (opzionale) | |
| * + DI: _setTestTasksDb / _resetTasksDb per test senza Dexie | |
| * | |
| * RC-FIX — Race Condition fixes (Sprint S-RC-STORES): | |
| * RC-1: updateStatus — setTimeout fuori dal set() callback (StrictMode double-invoke) | |
| * RC-2: cancelTask — setTimeout fuori dal set() callback (StrictMode double-invoke) | |
| * RC-3: retryTask — guard atomica dentro set() + setTimeout fuori (stale-read race) | |
| * RC-4: subscribeToEventRuntime — flag _aborted per cleanup pre-import (memory leak) | |
| */ | |
| import { create } from "zustand"; | |
| import { vfsDb, tasksDb, type StoredTask } from "@/lib/vfsDb"; | |
| // ─── Task status ────────────────────────────────────────────────────────────── | |
| export type TaskStatus = | |
| | "IDLE" | |
| | "QUEUED" | |
| | "RUNNING" | |
| | "SUCCESS" | |
| | "ERROR" | |
| | "TIMEOUT" | |
| | "CANCELLED"; | |
| // ─── AgentRuntime Phase (S744) ─────────────────────────────────────────────── | |
| // Macchina a stati sub-RUNNING dell'agente per run attiva. | |
| // Granularità più fine di TaskStatus.RUNNING — espone la fase corrente al frontend. | |
| // IDLE — nessun task attivo (default / stato iniziale) | |
| // PLANNING — classificazione task + pianificazione subtask (planTask) | |
| // EXECUTING — main for-loop in corso (tool calls, LLM iterations) | |
| // VERIFYING — candidato risposta trovato, goal verifier in esecuzione | |
| // DONE — risposta emessa, loop terminato con successo | |
| // ERROR — loop terminato per errore / timeout / abort | |
| export type AgentRuntimePhase = 'IDLE' | 'PLANNING' | 'EXECUTING' | 'VERIFYING' | 'DONE' | 'ERROR'; | |
| // ─── Task step (S36) ───────────────────────────────────────────────────────── | |
| export type TaskStepStatus = "pending" | "running" | "done" | "failed"; | |
| export interface TaskStep { | |
| id: string; | |
| label: string; // descrizione step (e.g. "Write tests for auth.ts") | |
| status: TaskStepStatus; | |
| startedAt: number; // ms timestamp | |
| finishedAt?: number; | |
| output?: string; // snippet output opzionale (≤ 500 chars) | |
| error?: string; // messaggio errore se failed | |
| } | |
| // ─── Task log entry ─────────────────────────────────────────────────────────── | |
| export interface TaskLog { | |
| at: number; | |
| level: "info" | "warn" | "error"; | |
| message: string; | |
| } | |
| // ─── Task artifact ──────────────────────────────────────────────────────────── | |
| export interface TaskArtifact { | |
| id: string; | |
| type: "file" | "code" | "data" | "image"; | |
| name: string; | |
| content: string; | |
| mimeType?: string; | |
| } | |
| // ─── Phase breakdown (Sprint 5 ITEM 14) ────────────────────────────────────── | |
| export interface PhaseBreakdown { | |
| classify_ms: number; | |
| plan_ms: number; | |
| coder_ms: number; | |
| verifier_ms: number; | |
| browser_ms: number; | |
| } | |
| // ─── Task action (Replit-style action card) ──────────────────────────────────── | |
| export type TaskActionType = | |
| | "file_open" | "file_write" | "file_read" | "file_delete" | |
| | "search_web" | "code_run" | "tool_call" | "api_call" | |
| | "thinking" | "plan" | "memory"; | |
| export interface TaskAction { | |
| id: string; | |
| type: TaskActionType; | |
| label: string; // e.g. "Opened api.ts" | |
| detail?: string; // e.g. file path or URL | |
| at: number; // ms timestamp | |
| durationMs?: number; // se completato | |
| done: boolean; | |
| // S396-NARR: narrative fields dal backend on_step | |
| explanation?: string; // frase narrativa (e.g. "Recupero dati meteo per Milano…") | |
| toolName?: string; // nome backend originale (e.g. "run_python", "get_weather") | |
| } | |
| // ─── Task ──────────────────────────────────────────────────────────────────── | |
| export interface Task { | |
| id: string; | |
| status: TaskStatus; | |
| goal: string; | |
| plan?: string[]; | |
| steps: TaskStep[]; // S36: per-step tracking | |
| currentStep: number; | |
| logs: TaskLog[]; | |
| actions: TaskAction[]; // action card stile Replit | |
| artifacts: TaskArtifact[]; | |
| startedAt: number; | |
| finishedAt?: number; | |
| error?: string; | |
| retryCount: number; | |
| maxRetries: number; | |
| phaseBreakdown?: PhaseBreakdown; // Sprint 5 ITEM 14 | |
| runtimePhase?: AgentRuntimePhase; // S744: fase sub-RUNNING dell'agente | |
| proofBadge?: { status: "ok" | "warn"; label: string }; // Visual Proof Badge | |
| } | |
| // ─── Store ──────────────────────────────────────────────────────────────────── | |
| interface TaskStoreState { | |
| tasks: Task[]; | |
| activeTaskId: string | null; | |
| hydrated: boolean; | |
| startTask: (goal: string, plan?: string[]) => string; | |
| updateStatus: (id: string, status: TaskStatus, error?: string) => void; | |
| addLog: (id: string, level: TaskLog["level"], message: string) => void; | |
| addAction: (id: string, action: Omit<TaskAction, "id" | "at">) => void; | |
| completeAction: (id: string, actionId: string, durationMs?: number) => void; | |
| addArtifact: (id: string, artifact: TaskArtifact) => void; | |
| advanceStep: (id: string) => void; | |
| cancelTask: (id: string) => void; | |
| retryTask: (id: string) => void; | |
| clearTasks: () => void; | |
| setActiveTask: (id: string | null) => void; | |
| setPhaseBreakdown: (id: string, bd: PhaseBreakdown) => void; // Sprint 5 ITEM 14 | |
| // S36: step actions | |
| addStep: (id: string, step: Omit<TaskStep, "id" | "startedAt">) => string; | |
| completeStep: (id: string, stepId: string, output?: string) => void; | |
| failStep: (id: string, stepId: string, error: string) => void; | |
| setRuntimePhase: (id: string, phase: AgentRuntimePhase) => void; // S744 | |
| setProofBadge: (id: string, badge: { status: "ok" | "warn"; label: string }) => void; | |
| _setHydrated: (tasks: Task[]) => void; | |
| } | |
| // ─── ID generation ──────────────────────────────────────────────────────────── | |
| let _idCounter = 0; | |
| function genId(): string { | |
| return `task-${Date.now()}-${++_idCounter}`; | |
| } | |
| function genActionId(): string { | |
| return `act-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; | |
| } | |
| function genStepId(): string { | |
| return `step-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; | |
| } | |
| // ─── S36: Dependency Injection per test ────────────────────────────────────── | |
| // Pattern: _setTestTasksDb(mockDb) sostituisce le chiamate Dexie con mock. | |
| // Identico a skillPersistence._setTestDb(). | |
| export interface TasksDbDeps { | |
| put: (task: StoredTask) => Promise<void>; | |
| putMany: (tasks: StoredTask[]) => Promise<void>; | |
| loadRecent: (limit?: number) => Promise<StoredTask[]>; | |
| pruneOlderThan: (days: number) => Promise<number>; | |
| clear: () => Promise<void>; | |
| } | |
| const _realDb: TasksDbDeps = { | |
| put: (t) => tasksDb.put(t), | |
| putMany: (ts) => tasksDb.putMany(ts), | |
| loadRecent: (n) => tasksDb.loadRecent(n), | |
| pruneOlderThan: (d) => tasksDb.pruneOlderThan(d), | |
| clear: () => vfsDb.tasks.clear().then(() => {}), | |
| }; | |
| let _db: TasksDbDeps = { ..._realDb }; | |
| /** Solo per test — sostituisce Dexie con un mock in-memory. */ | |
| export function _setTestTasksDb(mock: Partial<TasksDbDeps>): void { | |
| _db = { ..._realDb, ...mock }; | |
| } | |
| /** Solo per test — ripristina l'implementazione reale. */ | |
| export function _resetTasksDb(): void { | |
| _db = { ..._realDb }; | |
| } | |
| // ─── Helper: Task → StoredTask ──────────────────────────────────────────────── | |
| function toStored(t: Task): StoredTask { | |
| return { | |
| id: t.id, | |
| status: t.status, | |
| goal: t.goal, | |
| plan: t.plan ?? [], | |
| currentStep: t.currentStep, | |
| logs: t.logs.slice(-50), | |
| artifacts: t.artifacts, | |
| startedAt: t.startedAt, | |
| finishedAt: t.finishedAt, | |
| error: t.error, | |
| retryCount: t.retryCount, | |
| maxRetries: t.maxRetries, | |
| steps: t.steps as unknown[], | |
| }; | |
| } | |
| // ─── Concurrency cap ────────────────────────────────────────────────────────── | |
| const MAX_CONCURRENT_RUNNING = 2; | |
| // ─── Terminal statuses ──────────────────────────────────────────────────────── | |
| const TERMINAL_STATUSES: TaskStatus[] = ["SUCCESS", "ERROR", "TIMEOUT", "CANCELLED"]; | |
| // ─── S36: Resume window — task RUNNING < 30min al boot → candidato resume ──── | |
| // Se l'app è ripartita entro 30min, segna il task QUEUED per eventuale resume. | |
| // Se > 30min, segna CANCELLED (presumibilmente orphan). | |
| // GAP-TASKHYD-30MIN fix: esteso da 30min a 24h. | |
| // Il backend scheduler ha watchdog autonomo (STUCK_TIMEOUT_S=300s). Il cutoff da | |
| // 30min cancellava task ancora attivi sul backend — il risultato SSE arrivava ma | |
| // non trovava il task corrispondente e veniva scartato silenziosamente. | |
| export const RESUME_WINDOW_MS = 24 * 60 * 60 * 1000; // 24 ore | |
| /** S36: Logica di hydration intelligente. Pure function — testabile. */ | |
| export function resolveHydratedStatus( | |
| task: { status: string; startedAt: number }, | |
| now = Date.now(), | |
| ): TaskStatus { | |
| if (task.status !== "RUNNING" && task.status !== "QUEUED") { | |
| return task.status as TaskStatus; | |
| } | |
| const age = now - task.startedAt; | |
| // Task giovane → QUEUED (candidato resume manuale o automatico) | |
| // Task vecchio → CANCELLED (orphan presumibile) | |
| return age < RESUME_WINDOW_MS ? "QUEUED" : "CANCELLED"; | |
| } | |
| // S337-F4-FIX: timestamp di avvio sessione corrente. | |
| // Usato in _tryPromoteQueued per distinguere task avviati in questa sessione | |
| // da task hydratati da sessioni precedenti (bug Manus: "Nuova chat → pianificazione autonoma"). | |
| // Task hydratati hanno startedAt dalla sessione precedente → vengono scartati. | |
| // Margine di 5s copre il gap tra module load e il primo startTask dell'utente. | |
| const _SESSION_START = Date.now(); | |
| // ─── Queue promotion via DOM event (zero circular import) ──────────────────── | |
| function _tryPromoteQueued(tasks: Task[]): void { | |
| if (typeof window === "undefined") return; | |
| const running = tasks.filter(t => t.status === "RUNNING").length; | |
| if (running >= MAX_CONCURRENT_RUNNING) return; | |
| const next = tasks.find(t => t.status === "QUEUED"); | |
| if (!next) return; | |
| // S337-F4: previene auto-promozione di task hydratati da sessioni precedenti. | |
| // Root cause del bug "Nuova chat → pianificazione non richiesta": | |
| // hydrateTaskStore() imposta RUNNING/QUEUED → QUEUED per task < 30min. | |
| // Al primo updateStatus() terminale → _tryPromoteQueued() → dispatch evento. | |
| // Task da hydration hanno startedAt < _SESSION_START → ignorati. | |
| // Task avviati in questa sessione hanno startedAt ≥ _SESSION_START - 5s → promossi. | |
| if (next.startedAt < _SESSION_START - 5_000) return; | |
| window.dispatchEvent( | |
| new CustomEvent("agent:promote-queued", { | |
| detail: { taskId: next.id, goal: next.goal, plan: next.plan ?? [] }, | |
| }), | |
| ); | |
| } | |
| export const useTaskStore = create<TaskStoreState>((set, get) => ({ | |
| tasks: [], | |
| activeTaskId: null, | |
| hydrated: false, | |
| startTask: (goal, plan) => { | |
| const id = genId(); | |
| const task: Task = { | |
| id, | |
| status: "QUEUED", | |
| goal, | |
| plan: plan ?? [], | |
| steps: [], | |
| currentStep: 0, | |
| logs: [], | |
| actions: [], | |
| artifacts: [], | |
| startedAt: Date.now(), | |
| retryCount: 0, | |
| maxRetries: 3, | |
| }; | |
| set(s => ({ tasks: [task, ...s.tasks].slice(0, 20), activeTaskId: id })); | |
| _db.put(toStored(task)).catch(() => {}); | |
| return id; | |
| }, | |
| // RC-FIX-1: setTimeout spostato FUORI dal set() callback. | |
| // Ragione: Zustand set() deve essere una pure function che ritorna il nuovo stato. | |
| // In React StrictMode (dev) il callback è invocato 2× (il primo risultato è scartato). | |
| // Con setTimeout DENTRO set(), in StrictMode viene schedulato 2× → | |
| // _tryPromoteQueued() fires 2× → doppio CustomEvent "agent:promote-queued" → | |
| // il task parte due volte in parallelo (bug critico in dev, intermittente in prod). | |
| updateStatus: (id, status, error) => { | |
| set(s => { | |
| // S744: auto-map terminal TaskStatus → AgentRuntimePhase | |
| const _s744PhaseMap: Partial<Record<TaskStatus, AgentRuntimePhase>> = { | |
| SUCCESS: 'DONE', ERROR: 'ERROR', TIMEOUT: 'ERROR', CANCELLED: 'ERROR', | |
| }; | |
| const _s744AutoPhase = _s744PhaseMap[status as keyof typeof _s744PhaseMap]; | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| status, | |
| error, | |
| finishedAt: TERMINAL_STATUSES.includes(status) ? Date.now() : t.finishedAt, | |
| ...(_s744AutoPhase ? { runtimePhase: _s744AutoPhase } : {}), | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }); | |
| // RC-FIX-1: setTimeout DOPO set() — eseguito una volta sola, fuori dal ciclo Zustand | |
| if (TERMINAL_STATUSES.includes(status)) { | |
| setTimeout(() => _tryPromoteQueued(useTaskStore.getState().tasks), 50); | |
| } | |
| }, | |
| addLog: (id, level, message) => set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| logs: [...t.logs, { at: Date.now(), level, message }].slice(-100), | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }), | |
| addAction: (id, actionData) => set(s => { | |
| const action: TaskAction = { | |
| ...actionData, | |
| id: genActionId(), | |
| at: Date.now(), | |
| }; | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| actions: [...t.actions, action].slice(-100), // AUDIT-FE-1: allineato con logs slice(-100) | |
| }); | |
| return { tasks }; | |
| }), | |
| completeAction: (id, actionId, durationMs) => set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| actions: t.actions.map(a => a.id !== actionId ? a : { | |
| ...a, done: true, durationMs, | |
| }), | |
| }); | |
| return { tasks }; | |
| }), | |
| addArtifact: (id, artifact) => set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, artifacts: [...t.artifacts, artifact], | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }), | |
| advanceStep: (id) => set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, currentStep: t.currentStep + 1, | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }), | |
| // S36: step actions ────────────────────────────────────────────────────────── | |
| addStep: (id, stepData) => { | |
| const stepId = genStepId(); | |
| const step: TaskStep = { | |
| ...stepData, | |
| id: stepId, | |
| startedAt: Date.now(), | |
| }; | |
| set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| steps: [...t.steps, step].slice(-200), | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }); | |
| return stepId; | |
| }, | |
| completeStep: (id, stepId, output) => set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| steps: t.steps.map(st => st.id !== stepId ? st : { | |
| ...st, | |
| status: "done" as TaskStepStatus, | |
| finishedAt: Date.now(), | |
| output: output?.slice(0, 500), | |
| }), | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }), | |
| failStep: (id, stepId, error) => set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| steps: t.steps.map(st => st.id !== stepId ? st : { | |
| ...st, | |
| status: "failed" as TaskStepStatus, | |
| finishedAt: Date.now(), | |
| error, | |
| }), | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { tasks }; | |
| }), | |
| // RC-FIX-2: setTimeout spostato FUORI dal set() callback (stesso motivo di RC-FIX-1). | |
| cancelTask: (id) => { | |
| set(s => { | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, status: "CANCELLED" as TaskStatus, finishedAt: Date.now(), | |
| runtimePhase: 'IDLE' as AgentRuntimePhase, // GAP-AGENTRUNTIME-PHASE-LEAK | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| return { | |
| tasks, | |
| activeTaskId: s.activeTaskId === id ? null : s.activeTaskId, | |
| }; | |
| }); | |
| // RC-FIX-2: setTimeout DOPO set() | |
| setTimeout(() => _tryPromoteQueued(useTaskStore.getState().tasks), 50); | |
| }, | |
| // RC-FIX-3: doppio fix — | |
| // (a) Guard `retryCount >= maxRetries` spostata DENTRO set() per essere atomica: | |
| // tra get() e set() un'altra call potrebbe incrementare retryCount (stale-read race). | |
| // (b) setTimeout spostato FUORI dal set() callback (stesso motivo di RC-FIX-1/2). | |
| // Flag _shouldPromote: indica se set() ha effettivamente aggiornato il task | |
| // (falso se guard ha bloccato l'update) → setTimeout schedulato solo se necessario. | |
| retryTask: (id) => { | |
| let _shouldPromote = false; | |
| set(s => { | |
| // RC-FIX-3a: legge da s (stato corrente al momento di set()) — atomico, no stale read | |
| const task = s.tasks.find(t => t.id === id); | |
| if (!task || task.retryCount >= task.maxRetries) return s; // no change | |
| const tasks = s.tasks.map(t => t.id !== id ? t : { | |
| ...t, | |
| status: "QUEUED" as TaskStatus, | |
| error: undefined, | |
| finishedAt: undefined, | |
| retryCount: t.retryCount + 1, | |
| actions: [], | |
| steps: [], | |
| logs: [ | |
| ...t.logs, | |
| { at: Date.now(), level: "info" as const, message: `Retry ${t.retryCount + 1}/${t.maxRetries}` }, | |
| ], | |
| }); | |
| const updated = tasks.find(t => t.id === id); | |
| if (updated) _db.put(toStored(updated)).catch(() => {}); | |
| _shouldPromote = true; | |
| return { tasks }; | |
| }); | |
| // RC-FIX-3b: setTimeout DOPO set(), solo se l'update è avvenuto | |
| if (_shouldPromote) setTimeout(() => _tryPromoteQueued(useTaskStore.getState().tasks), 50); | |
| }, | |
| clearTasks: () => { | |
| _db.clear().catch(() => {}); | |
| set({ tasks: [], activeTaskId: null }); | |
| }, | |
| setActiveTask: (id) => set({ activeTaskId: id }), | |
| setPhaseBreakdown: (id, bd) => set(s => ({ // Sprint 5 ITEM 14 | |
| tasks: s.tasks.map(t => t.id !== id ? t : { ...t, phaseBreakdown: bd }), | |
| })), | |
| setRuntimePhase: (id, phase) => set(s => ({ | |
| // S744: aggiorna solo runtimePhase — nessuna scrittura Dexie (effimero per sessione) | |
| tasks: s.tasks.map(t => t.id !== id ? t : { ...t, runtimePhase: phase }), | |
| })), | |
| setProofBadge: (id, badge) => set(s => ({ | |
| // Visual Proof Badge — effimero per sessione, no Dexie | |
| tasks: s.tasks.map(t => t.id !== id ? t : { ...t, proofBadge: badge }), | |
| })), | |
| _setHydrated: (tasks) => set({ tasks, hydrated: true }), | |
| })); | |
| // ─── Hydrate from Dexie on app init (S36: resume logic) ───────────────────── | |
| export async function hydrateTaskStore(): Promise<void> { | |
| try { | |
| const stored = await _db.loadRecent(20); | |
| if (stored.length === 0) { | |
| useTaskStore.getState()._setHydrated([]); | |
| return; | |
| } | |
| const now = Date.now(); | |
| const tasks: Task[] = stored.map(s => ({ | |
| id: s.id, | |
| status: resolveHydratedStatus(s, now), // S36: smart resume | |
| goal: s.goal, | |
| plan: s.plan, | |
| steps: (s.steps ?? []) as TaskStep[], | |
| currentStep: s.currentStep, | |
| logs: s.logs as TaskLog[], | |
| actions: [], // actions non persistono (effimere per sessione) | |
| artifacts: s.artifacts as TaskArtifact[], | |
| startedAt: s.startedAt, | |
| finishedAt: s.finishedAt, | |
| error: s.error, | |
| retryCount: s.retryCount, | |
| maxRetries: s.maxRetries, | |
| })); | |
| useTaskStore.getState()._setHydrated(tasks); | |
| await _db.putMany(tasks.map(toStored)); | |
| await _db.pruneOlderThan(7); | |
| } catch { | |
| useTaskStore.getState()._setHydrated([]); | |
| } | |
| } | |
| // ─── S36: EventRuntime bridge (opzionale — chiama on app init se vuoi) ─────── | |
| /** | |
| * Collega EventRuntime → taskStore. | |
| * Da chiamare una volta all'init (agentSSE.ts o App.tsx). | |
| * Ritorna unsub per cleanup. | |
| * | |
| * NOTA: agentSSE.ts aggiorna già taskStore direttamente — questo bridge | |
| * è per componenti che vogliono reagire agli eventi senza importare agentSSE. | |
| * | |
| * RC-FIX-4: flag _aborted per gestire il caso in cui il cleanup venga chiamato | |
| * prima che il dynamic import risolva. Senza il flag, u1/u2/u3 vengono sottoscritte | |
| * DOPO che unsub() è già stato invocato → leak permanente: nessun modo di | |
| * de-sottoscriverle perché unsub non è ancora valorizzato al momento del cleanup. | |
| */ | |
| export function subscribeToEventRuntime(): () => void { | |
| let unsub: (() => void) | null = null; | |
| let _aborted = false; // RC-FIX-4: segnala cleanup anticipato | |
| import("@/core/events/EventRuntime").then(({ eventRuntime }) => { | |
| // RC-FIX-4: import risolto dopo cleanup → non sottoscrivere (evita leak) | |
| if (_aborted) return; | |
| const u1 = eventRuntime.on("task_complete", (e) => { | |
| const state = useTaskStore.getState(); | |
| const task = state.tasks.find(t => t.id === e.taskId); | |
| if (task && task.status === "RUNNING") { | |
| state.updateStatus(e.taskId, "SUCCESS"); | |
| } | |
| }); | |
| const u2 = eventRuntime.on("task_error", (e) => { | |
| const state = useTaskStore.getState(); | |
| const task = state.tasks.find(t => t.id === e.taskId); | |
| if (task && task.status === "RUNNING") { | |
| state.updateStatus(e.taskId, "ERROR", e.message); | |
| } | |
| }); | |
| const u3 = eventRuntime.on("task_cancel", (e) => { | |
| const state = useTaskStore.getState(); | |
| const task = state.tasks.find(t => t.id === e.taskId); | |
| if (task && (task.status === "RUNNING" || task.status === "QUEUED")) { | |
| state.cancelTask(e.taskId); | |
| } | |
| }); | |
| unsub = () => { u1(); u2(); u3(); }; | |
| }).catch(() => {}); | |
| return () => { | |
| _aborted = true; // RC-FIX-4: blocca sottoscrizioni se import non ancora risolto | |
| unsub?.(); | |
| }; | |
| } | |
| // ─── Selectors ──────────────────────────────────────────────────────────────── | |
| export const selectActiveTask = (s: TaskStoreState) => | |
| s.tasks.find(t => t.id === s.activeTaskId) ?? null; | |
| export const selectRunningTasks = (s: TaskStoreState) => | |
| s.tasks.filter(t => t.status === "RUNNING" || t.status === "QUEUED"); | |
| export const selectQueuedTasks = (s: TaskStoreState) => | |
| s.tasks.filter(t => t.status === "QUEUED"); | |
| export const selectTaskById = (id: string) => (s: TaskStoreState) => | |
| s.tasks.find(t => t.id === id) ?? null; | |
| export const selectQueueLength = (s: TaskStoreState) => | |
| s.tasks.filter(t => t.status === "QUEUED").length; | |
| export const selectTaskSteps = (id: string) => (s: TaskStoreState) => | |
| s.tasks.find(t => t.id === id)?.steps ?? []; | |
| // ─── Primitive selectors — React#185 safe ───────────────────────────────────── | |
| // Usare questi invece di selectRunningTasks/selectQueuedTasks nei componenti. | |
| // selectRunningTasks usa .filter() → nuovo array ad ogni call → loop infinito | |
| // con useSyncExternalStore (Zustand v4 + React 18 concurrent mode). | |
| export const selectRunningTaskCount = (s: TaskStoreState) => | |
| s.tasks.filter(t => t.status === "RUNNING" || t.status === "QUEUED").length; | |
| export const selectFirstRunningId = (s: TaskStoreState) => | |
| s.tasks.find(t => t.status === "RUNNING" || t.status === "QUEUED")?.id ?? null; | |
| export const selectIsRunning = (s: TaskStoreState) => | |
| s.tasks.some(t => t.status === "RUNNING" || t.status === "QUEUED"); | |
| // S744: selettore fase sub-RUNNING per un task specifico | |
| // Usare con shallow compare: useTaskStore(selectRuntimePhase(id), shallow) | |
| export const selectRuntimePhase = (id: string) => (s: TaskStoreState): AgentRuntimePhase => | |
| s.tasks.find(t => t.id === id)?.runtimePhase ?? 'IDLE'; | |