diff --git a/client/src/components/digital-twin/tabs/EnrichTab.jsx b/client/src/components/digital-twin/tabs/EnrichTab.jsx index 4c5e9d89..66f8bccd 100644 --- a/client/src/components/digital-twin/tabs/EnrichTab.jsx +++ b/client/src/components/digital-twin/tabs/EnrichTab.jsx @@ -45,7 +45,7 @@ export default function EnrichTab({ onRefresh }) { const loadData = useCallback(async () => { setLoading(true); - const progressData = await api.getSoulEnrichProgress().catch(() => null); + const progressData = await api.getDigitalTwinEnrichProgress().catch(() => null); setProgress(progressData); setLoading(false); }, []); @@ -114,22 +114,26 @@ export default function EnrichTab({ onRefresh }) { } setSavingWritingStyle(true); - await api.createSoulDocument({ - filename: 'WRITING_STYLE.md', - title: 'Writing Style', - category: 'core', - content: writingAnalysis.suggestedContent - }).catch(async () => { - // Document might exist, try to update by fetching ID - const docs = await api.getSoulDocuments(); - const existing = docs.find(d => d.filename === 'WRITING_STYLE.md'); - if (existing) { - return api.updateSoulDocument(existing.id, { - content: writingAnalysis.suggestedContent - }); - } - }); - toast.success('Writing style saved'); + try { + await api.createSoulDocument({ + filename: 'WRITING_STYLE.md', + title: 'Writing Style', + category: 'core', + content: writingAnalysis.suggestedContent + }).catch(async () => { + // Document might exist, try to update by fetching ID + const docs = await api.getDigitalTwinDocuments(); + const existing = docs.find(d => d.filename === 'WRITING_STYLE.md'); + if (existing) { + return api.updateSoulDocument(existing.id, { + content: writingAnalysis.suggestedContent + }); + } + }); + toast.success('Writing style saved'); + } catch (err) { + toast.error(`Failed to save writing style: ${err.message}`); + } setSavingWritingStyle(false); onRefresh(); }; @@ -137,11 +141,12 @@ export default function EnrichTab({ onRefresh }) { const loadQuestion = useCallback(async (categoryId, skipList = []) => { setLoadingQuestion(true); try { - const question = await api.getSoulEnrichQuestion(categoryId, undefined, undefined, skipList.length ? skipList : undefined); + const question = await api.getDigitalTwinEnrichQuestion(categoryId, undefined, undefined, skipList.length ? skipList : undefined); setCurrentQuestion(question); setAnswer(''); setScaleValue(null); - } catch { + } catch (err) { + console.warn(`⚠️ Failed to load enrichment question: ${err.message}`); setCurrentQuestion(null); } finally { setLoadingQuestion(false); diff --git a/client/src/hooks/useAutoRefetch.js b/client/src/hooks/useAutoRefetch.js index 5d7d0913..75da260d 100644 --- a/client/src/hooks/useAutoRefetch.js +++ b/client/src/hooks/useAutoRefetch.js @@ -27,8 +27,9 @@ export function useAutoRefetch(fetchFn, intervalMs) { if (cancelled) return; setData(result); setLoading(false); - } catch { + } catch (err) { // Keep prior data on failure, just clear loading state + console.warn(`⚠️ Auto-refetch failed: ${err.message}`); if (!cancelled) setLoading(false); } }; diff --git a/client/src/pages/CharacterSheet.jsx b/client/src/pages/CharacterSheet.jsx index a7c6e971..c292e521 100644 --- a/client/src/pages/CharacterSheet.jsx +++ b/client/src/pages/CharacterSheet.jsx @@ -5,28 +5,12 @@ import { } from 'lucide-react'; import toast from '../components/ui/Toast'; import { timeAgo } from '../utils/formatters'; -import { generateAvatar } from '../services/api'; +import api, { generateAvatar } from '../services/api'; import socket from '../services/socket'; -const request = async (endpoint, options = {}) => { - const response = await fetch(`/api/character${endpoint}`, { - headers: { 'Content-Type': 'application/json', ...options.headers }, - ...options - }); - let data = null; - try { data = await response.json(); } catch { /* non-JSON body */ } - if (!response.ok) { - const message = data?.message || `Request failed with status ${response.status}`; - const error = new Error(message); - error.status = response.status; - throw error; - } - return data; -}; - -const get = () => request(''); -const post = (path, body) => request(path, { method: 'POST', body: JSON.stringify(body) }); -const put = (body) => request('', { method: 'PUT', body: JSON.stringify(body) }); +const charGet = () => api.get('/character'); +const charPost = (path, body) => api.post(`/character${path}`, body); +const charPut = (body) => api.put('/character', body); // D&D 5e XP thresholds (must match server) const XP_THRESHOLDS = [ @@ -91,7 +75,7 @@ export default function CharacterSheet() { const load = useCallback(async () => { setLoadError(null); try { - const data = await get(); + const data = await charGet(); if (!data || data.error) { setLoadError('Failed to load character data'); return; @@ -143,7 +127,7 @@ export default function CharacterSheet() { const handleDamage = async () => { try { - const result = await post('/damage', { diceNotation: dmgDice, description: dmgDesc || undefined }); + const result = await charPost('/damage', { diceNotation: dmgDice, description: dmgDesc || undefined }); setChar(result.character); setActiveAction(null); setDmgDice('1d6'); @@ -153,14 +137,14 @@ export default function CharacterSheet() { const handleShortRest = async () => { try { - const result = await post('/rest', { type: 'short' }); + const result = await charPost('/rest', { type: 'short' }); setChar(result.character); } catch (err) { toast.error(err.message || 'Failed to take short rest'); } }; const handleLongRest = async () => { try { - const result = await post('/rest', { type: 'long' }); + const result = await charPost('/rest', { type: 'long' }); setChar(result.character); } catch (err) { toast.error(err.message || 'Failed to take long rest'); } }; @@ -168,7 +152,7 @@ export default function CharacterSheet() { const handleAddXp = async () => { if (!xpAmount) return; try { - const result = await post('/xp', { amount: Number(xpAmount), source: 'manual', description: xpDesc || undefined }); + const result = await charPost('/xp', { amount: Number(xpAmount), source: 'manual', description: xpDesc || undefined }); setChar(result.character); setActiveAction(null); setXpAmount(''); @@ -182,7 +166,7 @@ export default function CharacterSheet() { const body = { description: evtDesc }; if (evtXp) body.xp = Number(evtXp); if (evtDice) body.diceNotation = evtDice; - const result = await post('/event', body); + const result = await charPost('/event', body); setChar(result.character); setActiveAction(null); setEvtDesc(''); @@ -194,7 +178,7 @@ export default function CharacterSheet() { const handleSync = async (type) => { setSyncing(type); try { - const result = await post(`/sync/${type}`, {}); + const result = await charPost(`/sync/${type}`, {}); setChar(result.character); } catch (err) { toast.error(err.message || `Failed to sync ${type}`); @@ -206,7 +190,7 @@ export default function CharacterSheet() { const handleNameSave = async () => { try { if (nameVal.trim() && nameVal !== char.name) { - const data = await put({ name: nameVal.trim() }); + const data = await charPut({ name: nameVal.trim() }); setChar(data); } } catch (err) { toast.error(err.message || 'Failed to save name'); } @@ -216,7 +200,7 @@ export default function CharacterSheet() { const handleClassSave = async () => { try { if (classVal.trim() && classVal !== char.class) { - const data = await put({ class: classVal.trim() }); + const data = await charPut({ class: classVal.trim() }); setChar(data); } } catch (err) { toast.error(err.message || 'Failed to save class'); } diff --git a/client/src/pages/DataDog.jsx b/client/src/pages/DataDog.jsx index a1fc2708..09a10c9d 100644 --- a/client/src/pages/DataDog.jsx +++ b/client/src/pages/DataDog.jsx @@ -100,10 +100,10 @@ export default function DataDog() { ...(formData.appKey && { appKey: formData.appKey }) }; - await api.post('/datadog/instances', payload); + const saved = await api.post('/datadog/instances', payload); toast.success(`DataDog instance "${payload.name}" saved successfully`); - await loadInstances(); + setInstances(prev => ({ ...prev, [saved.id]: saved })); handleCancel(); } catch (error) { console.error(`Failed to save DataDog instance: ${error.message}`); @@ -125,7 +125,11 @@ export default function DataDog() { try { await api.delete(`/datadog/instances/${instanceId}`); toast.success(`DataDog instance "${instanceId}" deleted`); - await loadInstances(); + setInstances(prev => { + const next = { ...prev }; + delete next[instanceId]; + return next; + }); } catch (error) { console.error(`Failed to delete DataDog instance: ${error.message}`); toast.error(`Failed to delete: ${error.message}`); diff --git a/client/src/pages/FeatureAgentDetail.jsx b/client/src/pages/FeatureAgentDetail.jsx index 92fb0c9d..68a42561 100644 --- a/client/src/pages/FeatureAgentDetail.jsx +++ b/client/src/pages/FeatureAgentDetail.jsx @@ -46,19 +46,19 @@ export default function FeatureAgentDetail() { }, [fetchAgent, id]); const handleStart = useCallback((agentId) => { - api.startFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Activated'); }).catch(() => {}); + api.startFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Activated'); }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handlePause = useCallback((agentId) => { - api.pauseFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Paused'); }).catch(() => {}); + api.pauseFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Paused'); }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleResume = useCallback((agentId) => { - api.resumeFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Resumed'); }).catch(() => {}); + api.resumeFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Resumed'); }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleStop = useCallback((agentId) => { - api.stopFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Stopped'); }).catch(() => {}); + api.stopFeatureAgent(agentId).then(data => { setAgent(prev => ({ ...prev, ...data })); toast.success('Stopped'); }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleTrigger = useCallback((agentId) => { - api.triggerFeatureAgent(agentId).then(() => toast.success('Run triggered')).catch(() => {}); + api.triggerFeatureAgent(agentId).then(() => toast.success('Run triggered')).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleSave = useCallback((saved) => { diff --git a/client/src/pages/FeatureAgents.jsx b/client/src/pages/FeatureAgents.jsx index 3cc23810..bd813290 100644 --- a/client/src/pages/FeatureAgents.jsx +++ b/client/src/pages/FeatureAgents.jsx @@ -33,41 +33,41 @@ export default function FeatureAgents() { api.startFeatureAgent(id).then(agent => { setAgents(prev => prev.map(a => a.id === id ? { ...a, ...agent } : a)); toast.success('Feature agent activated'); - }).catch(() => {}); + }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handlePause = useCallback((id) => { api.pauseFeatureAgent(id).then(agent => { setAgents(prev => prev.map(a => a.id === id ? { ...a, ...agent } : a)); toast.success('Feature agent paused'); - }).catch(() => {}); + }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleResume = useCallback((id) => { api.resumeFeatureAgent(id).then(agent => { setAgents(prev => prev.map(a => a.id === id ? { ...a, ...agent } : a)); toast.success('Feature agent resumed'); - }).catch(() => {}); + }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleStop = useCallback((id) => { api.stopFeatureAgent(id).then(agent => { setAgents(prev => prev.map(a => a.id === id ? { ...a, ...agent } : a)); toast.success('Feature agent stopped'); - }).catch(() => {}); + }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleTrigger = useCallback((id) => { api.triggerFeatureAgent(id).then(() => { toast.success('Run triggered'); - }).catch(() => {}); + }).catch(err => toast.error(err.message || 'Action failed')); }, []); const handleDelete = useCallback((id) => { api.deleteFeatureAgent(id).then(() => { setAgents(prev => prev.filter(a => a.id !== id)); toast.success('Feature agent deleted'); - }).catch(() => {}); + }).catch(err => toast.error(err.message || 'Action failed')); }, []); const activeCount = agents.filter(a => a.status === 'active').length; diff --git a/client/src/pages/Jira.jsx b/client/src/pages/Jira.jsx index ef94e49c..eb9804d9 100644 --- a/client/src/pages/Jira.jsx +++ b/client/src/pages/Jira.jsx @@ -43,7 +43,7 @@ export default function Jira() { const response = await api.get('/jira/instances'); setInstances(response.instances || {}); } catch (error) { - console.error('Failed to load JIRA instances:', error); + console.error(`Failed to load JIRA instances: ${error.message}`); toast.error(`Failed to load JIRA instances: ${error.message}`); } finally { setLoading(false); @@ -102,13 +102,13 @@ export default function Jira() { apiToken: formData.apiToken }; - await api.post('/jira/instances', payload); + const saved = await api.post('/jira/instances', payload); toast.success(`JIRA instance "${payload.name}" saved successfully`); - await loadInstances(); + setInstances(prev => ({ ...prev, [saved.id]: saved })); handleCancel(); } catch (error) { - console.error('Failed to save JIRA instance:', error); + console.error(`Failed to save JIRA instance: ${error.message}`); setSaveError(error.message); toast.error(`Failed to save: ${error.message}`); } finally { @@ -127,9 +127,13 @@ export default function Jira() { try { await api.delete(`/jira/instances/${instanceId}`); toast.success(`JIRA instance "${instanceId}" deleted`); - await loadInstances(); + setInstances(prev => { + const next = { ...prev }; + delete next[instanceId]; + return next; + }); } catch (error) { - console.error('Failed to delete JIRA instance:', error); + console.error(`Failed to delete JIRA instance: ${error.message}`); toast.error(`Failed to delete: ${error.message}`); } }; diff --git a/server/lib/telegramClient.js b/server/lib/telegramClient.js index 9bd84f11..cd6fb099 100644 --- a/server/lib/telegramClient.js +++ b/server/lib/telegramClient.js @@ -8,6 +8,8 @@ import { EventEmitter } from 'events'; const BASE_URL = 'https://api.telegram.org/bot'; const POLL_TIMEOUT_SEC = 30; const API_TIMEOUT_MS = 10_000; // regular calls: sendMessage, editMessageText, etc. +const RETRY_DELAY_API_ERROR_MS = 5_000; +const RETRY_DELAY_NETWORK_ERROR_MS = 2_000; function buildUrl(token, method) { return `${BASE_URL}${token}/${method}`; @@ -57,14 +59,14 @@ export function createTelegramBot(token, opts = {}) { const json = await res.json(); if (!json.ok) { // Retry after a delay on API errors - await new Promise(r => setTimeout(r, 5000)); + await new Promise(r => setTimeout(r, RETRY_DELAY_API_ERROR_MS)); continue; } updates = json.result; } catch { // Aborted (stopPolling called) or network error if (!polling) break; - await new Promise(r => setTimeout(r, 2000)); + await new Promise(r => setTimeout(r, RETRY_DELAY_NETWORK_ERROR_MS)); continue; } diff --git a/server/routes/datadog.js b/server/routes/datadog.js index ac2adb75..671941d6 100644 --- a/server/routes/datadog.js +++ b/server/routes/datadog.js @@ -4,7 +4,7 @@ import express from 'express'; import * as datadogService from '../services/datadog.js'; -import { ServerError } from '../lib/errorHandler.js'; +import { asyncHandler, ServerError } from '../lib/errorHandler.js'; const router = express.Router(); @@ -24,118 +24,98 @@ function sanitizeInstance(instance) { * GET /api/datadog/instances * Get all DataDog instances */ -router.get('/instances', async (req, res, next) => { - try { - const config = await datadogService.getInstances(); - - const sanitized = { - instances: Object.fromEntries( - Object.entries(config.instances ?? {}).map(([id, instance]) => [id, sanitizeInstance(instance)]) - ) - }; - - res.json(sanitized); - } catch (error) { - next(error); - } -}); +router.get('/instances', asyncHandler(async (req, res) => { + const config = await datadogService.getInstances(); + + const sanitized = { + instances: Object.fromEntries( + Object.entries(config.instances ?? {}).map(([id, instance]) => [id, sanitizeInstance(instance)]) + ) + }; + + res.json(sanitized); +})); /** * POST /api/datadog/instances * Create or update DataDog instance */ -router.post('/instances', async (req, res, next) => { - try { - const { id, name, site, apiKey, appKey } = req.body; - - if (!id || !name || !site) { - throw new ServerError('Missing required fields: id, name, site', { - status: 400, - code: 'INVALID_INPUT' - }); - } - - // For new instances, both keys are required - const config = await datadogService.getInstances(); - const isNew = !config.instances[id]; - if (isNew && (!apiKey || !appKey)) { - throw new ServerError('API Key and Application Key are required for new instances', { - status: 400, - code: 'INVALID_INPUT' - }); - } - - const instance = await datadogService.upsertInstance(id, { - name, - site, - ...(apiKey && { apiKey }), - ...(appKey && { appKey }) +router.post('/instances', asyncHandler(async (req, res) => { + const { id, name, site, apiKey, appKey } = req.body; + + if (!id || !name || !site) { + throw new ServerError('Missing required fields: id, name, site', { + status: 400, + code: 'INVALID_INPUT' }); + } - res.json(sanitizeInstance(instance)); - } catch (error) { - next(error); + // For new instances, both keys are required + const config = await datadogService.getInstances(); + const isNew = !config.instances[id]; + if (isNew && (!apiKey || !appKey)) { + throw new ServerError('API Key and Application Key are required for new instances', { + status: 400, + code: 'INVALID_INPUT' + }); } -}); + + const instance = await datadogService.upsertInstance(id, { + name, + site, + ...(apiKey && { apiKey }), + ...(appKey && { appKey }) + }); + + res.json(sanitizeInstance(instance)); +})); /** * DELETE /api/datadog/instances/:id * Delete DataDog instance */ -router.delete('/instances/:id', async (req, res, next) => { - try { - await datadogService.deleteInstance(req.params.id); - res.json({ success: true }); - } catch (error) { - next(error); - } -}); +router.delete('/instances/:id', asyncHandler(async (req, res) => { + await datadogService.deleteInstance(req.params.id); + res.json({ success: true }); +})); /** * POST /api/datadog/instances/:id/test * Test DataDog instance connection */ -router.post('/instances/:id/test', async (req, res, next) => { - try { - const result = await datadogService.testConnection(req.params.id); - res.json(result); - } catch (error) { - next(error); - } -}); +router.post('/instances/:id/test', asyncHandler(async (req, res) => { + const result = await datadogService.testConnection(req.params.id); + res.json(result); +})); /** * POST /api/datadog/instances/:id/search-errors * Search for errors in DataDog logs */ -router.post('/instances/:id/search-errors', async (req, res, next) => { - try { - const { serviceName, environment, fromTime } = req.body; - - if (!serviceName) { - throw new ServerError('serviceName is required', { - status: 400, - code: 'INVALID_INPUT' - }); - } - - if (fromTime && isNaN(Date.parse(fromTime))) { - throw new ServerError('fromTime must be a valid ISO 8601 date string', { - status: 400, - code: 'INVALID_INPUT' - }); - } - - const result = await datadogService.searchErrors( - req.params.id, - serviceName, - environment, - fromTime - ); - res.json(result); - } catch (error) { - next(error); +router.post('/instances/:id/search-errors', asyncHandler(async (req, res) => { + const { serviceName, environment, fromTime } = req.body; + + if (!serviceName) { + throw new ServerError('serviceName is required', { + status: 400, + code: 'INVALID_INPUT' + }); } -}); + + if (fromTime && isNaN(Date.parse(fromTime))) { + throw new ServerError('fromTime must be a valid ISO 8601 date string', { + status: 400, + code: 'INVALID_INPUT' + }); + } + + const result = await datadogService.searchErrors( + req.params.id, + serviceName, + environment, + fromTime + ); + res.json(result); +})); export default router; diff --git a/server/routes/jira.js b/server/routes/jira.js index b6254f50..abc83180 100644 --- a/server/routes/jira.js +++ b/server/routes/jira.js @@ -6,7 +6,7 @@ import express from 'express'; import * as jiraService from '../services/jira.js'; import * as jiraReports from '../services/jiraReports.js'; import { getAppById } from '../services/apps.js'; -import { ServerError } from '../lib/errorHandler.js'; +import { asyncHandler, ServerError } from '../lib/errorHandler.js'; const router = express.Router(); @@ -14,278 +14,222 @@ const router = express.Router(); * GET /api/jira/instances * Get all JIRA instances */ -router.get('/instances', async (req, res, next) => { - try { - const config = await jiraService.getInstances(); - - // Don't send API tokens to client - const sanitized = { - instances: Object.fromEntries( - Object.entries(config.instances).map(([id, instance]) => [ - id, - { - id: instance.id, - name: instance.name, - baseUrl: instance.baseUrl, - email: instance.email, - hasApiToken: !!instance.apiToken, - tokenUpdatedAt: instance.tokenUpdatedAt, - createdAt: instance.createdAt, - updatedAt: instance.updatedAt - } - ]) - ) - }; - - res.json(sanitized); - } catch (error) { - next(error); - } -}); +router.get('/instances', asyncHandler(async (req, res) => { + const config = await jiraService.getInstances(); + + // Don't send API tokens to client + const sanitized = { + instances: Object.fromEntries( + Object.entries(config.instances).map(([id, instance]) => [ + id, + { + id: instance.id, + name: instance.name, + baseUrl: instance.baseUrl, + email: instance.email, + hasApiToken: !!instance.apiToken, + tokenUpdatedAt: instance.tokenUpdatedAt, + createdAt: instance.createdAt, + updatedAt: instance.updatedAt + } + ]) + ) + }; + + res.json(sanitized); +})); /** * POST /api/jira/instances * Create or update JIRA instance */ -router.post('/instances', async (req, res, next) => { - try { - const { id, name, baseUrl, email, apiToken } = req.body; - - if (!id || !name || !baseUrl || !email || !apiToken) { - throw new ServerError('Missing required fields', { - status: 400, - code: 'INVALID_INPUT' - }); - } +router.post('/instances', asyncHandler(async (req, res) => { + const { id, name, baseUrl, email, apiToken } = req.body; - const instance = await jiraService.upsertInstance(id, { - name, - baseUrl, - email, - apiToken + if (!id || !name || !baseUrl || !email || !apiToken) { + throw new ServerError('Missing required fields', { + status: 400, + code: 'INVALID_INPUT' }); - - // Don't send API token back - const sanitized = { - id: instance.id, - name: instance.name, - baseUrl: instance.baseUrl, - email: instance.email, - hasApiToken: true, - tokenUpdatedAt: instance.tokenUpdatedAt, - createdAt: instance.createdAt, - updatedAt: instance.updatedAt - }; - - res.json(sanitized); - } catch (error) { - next(error); } -}); + + const instance = await jiraService.upsertInstance(id, { + name, + baseUrl, + email, + apiToken + }); + + // Don't send API token back + const sanitized = { + id: instance.id, + name: instance.name, + baseUrl: instance.baseUrl, + email: instance.email, + hasApiToken: true, + tokenUpdatedAt: instance.tokenUpdatedAt, + createdAt: instance.createdAt, + updatedAt: instance.updatedAt + }; + + res.json(sanitized); +})); /** * DELETE /api/jira/instances/:id * Delete JIRA instance */ -router.delete('/instances/:id', async (req, res, next) => { - try { - await jiraService.deleteInstance(req.params.id); - res.json({ success: true }); - } catch (error) { - next(error); - } -}); +router.delete('/instances/:id', asyncHandler(async (req, res) => { + await jiraService.deleteInstance(req.params.id); + res.json({ success: true }); +})); /** * POST /api/jira/instances/:id/test * Test JIRA instance connection */ -router.post('/instances/:id/test', async (req, res, next) => { - try { - const result = await jiraService.testConnection(req.params.id); - res.json(result); - } catch (error) { - next(error); - } -}); +router.post('/instances/:id/test', asyncHandler(async (req, res) => { + const result = await jiraService.testConnection(req.params.id); + res.json(result); +})); /** * GET /api/jira/instances/:id/projects * Get projects for JIRA instance */ -router.get('/instances/:id/projects', async (req, res, next) => { - try { - const projects = await jiraService.getProjects(req.params.id); - res.json(projects); - } catch (error) { - next(error); - } -}); +router.get('/instances/:id/projects', asyncHandler(async (req, res) => { + const projects = await jiraService.getProjects(req.params.id); + res.json(projects); +})); /** * POST /api/jira/instances/:id/tickets * Create JIRA ticket */ -router.post('/instances/:id/tickets', async (req, res, next) => { - try { - const result = await jiraService.createTicket(req.params.id, req.body); - res.json(result); - } catch (error) { - next(error); - } -}); +router.post('/instances/:id/tickets', asyncHandler(async (req, res) => { + const result = await jiraService.createTicket(req.params.id, req.body); + res.json(result); +})); /** * PUT /api/jira/instances/:instanceId/tickets/:ticketId * Update JIRA ticket */ -router.put('/instances/:instanceId/tickets/:ticketId', async (req, res, next) => { - try { - const result = await jiraService.updateTicket( - req.params.instanceId, - req.params.ticketId, - req.body - ); - res.json(result); - } catch (error) { - next(error); - } -}); +router.put('/instances/:instanceId/tickets/:ticketId', asyncHandler(async (req, res) => { + const result = await jiraService.updateTicket( + req.params.instanceId, + req.params.ticketId, + req.body + ); + res.json(result); +})); /** * POST /api/jira/instances/:instanceId/tickets/:ticketId/comments * Add comment to JIRA ticket */ -router.post('/instances/:instanceId/tickets/:ticketId/comments', async (req, res, next) => { - try { - const { comment } = req.body; - - if (!comment) { - throw new ServerError('Comment is required', { - status: 400, - code: 'INVALID_INPUT' - }); - } - - const result = await jiraService.addComment( - req.params.instanceId, - req.params.ticketId, - comment - ); +router.post('/instances/:instanceId/tickets/:ticketId/comments', asyncHandler(async (req, res) => { + const { comment } = req.body; - res.json(result); - } catch (error) { - next(error); + if (!comment) { + throw new ServerError('Comment is required', { + status: 400, + code: 'INVALID_INPUT' + }); } -}); + + const result = await jiraService.addComment( + req.params.instanceId, + req.params.ticketId, + comment + ); + + res.json(result); +})); /** * GET /api/jira/instances/:instanceId/tickets/:ticketId/transitions * Get available transitions for a ticket */ -router.get('/instances/:instanceId/tickets/:ticketId/transitions', async (req, res, next) => { - try { - const transitions = await jiraService.getTransitions( - req.params.instanceId, - req.params.ticketId - ); - res.json(transitions); - } catch (error) { - next(error); - } -}); +router.get('/instances/:instanceId/tickets/:ticketId/transitions', asyncHandler(async (req, res) => { + const transitions = await jiraService.getTransitions( + req.params.instanceId, + req.params.ticketId + ); + res.json(transitions); +})); /** * DELETE /api/jira/instances/:instanceId/tickets/:ticketId * Delete a JIRA ticket */ -router.delete('/instances/:instanceId/tickets/:ticketId', async (req, res, next) => { - try { - const result = await jiraService.deleteTicket( - req.params.instanceId, - req.params.ticketId - ); - res.json(result); - } catch (error) { - next(error); - } -}); +router.delete('/instances/:instanceId/tickets/:ticketId', asyncHandler(async (req, res) => { + const result = await jiraService.deleteTicket( + req.params.instanceId, + req.params.ticketId + ); + res.json(result); +})); /** * POST /api/jira/instances/:instanceId/tickets/:ticketId/transition * Transition JIRA ticket status */ -router.post('/instances/:instanceId/tickets/:ticketId/transition', async (req, res, next) => { - try { - const { transitionId } = req.body; - - if (!transitionId) { - throw new ServerError('Transition ID is required', { - status: 400, - code: 'INVALID_INPUT' - }); - } +router.post('/instances/:instanceId/tickets/:ticketId/transition', asyncHandler(async (req, res) => { + const { transitionId } = req.body; - const result = await jiraService.transitionTicket( - req.params.instanceId, - req.params.ticketId, - transitionId - ); - - res.json(result); - } catch (error) { - next(error); + if (!transitionId) { + throw new ServerError('Transition ID is required', { + status: 400, + code: 'INVALID_INPUT' + }); } -}); + + const result = await jiraService.transitionTicket( + req.params.instanceId, + req.params.ticketId, + transitionId + ); + + res.json(result); +})); /** * GET /api/jira/instances/:instanceId/my-sprint-tickets/:projectKey * Get tickets assigned to current user in active sprint for a project */ -router.get('/instances/:instanceId/my-sprint-tickets/:projectKey', async (req, res, next) => { - try { - const tickets = await jiraService.getMyCurrentSprintTickets( - req.params.instanceId, - req.params.projectKey - ); - res.json(tickets); - } catch (error) { - next(error); - } -}); +router.get('/instances/:instanceId/my-sprint-tickets/:projectKey', asyncHandler(async (req, res) => { + const tickets = await jiraService.getMyCurrentSprintTickets( + req.params.instanceId, + req.params.projectKey + ); + res.json(tickets); +})); /** * GET /api/jira/instances/:instanceId/boards/:boardId/sprints * Get active sprints for a board */ -router.get('/instances/:instanceId/boards/:boardId/sprints', async (req, res, next) => { - try { - const sprints = await jiraService.getActiveSprints( - req.params.instanceId, - req.params.boardId - ); - res.json(sprints); - } catch (error) { - next(error); - } -}); +router.get('/instances/:instanceId/boards/:boardId/sprints', asyncHandler(async (req, res) => { + const sprints = await jiraService.getActiveSprints( + req.params.instanceId, + req.params.boardId + ); + res.json(sprints); +})); /** * GET /api/jira/instances/:instanceId/projects/:projectKey/epics?q=search * Search for epics by name in a project */ -router.get('/instances/:instanceId/projects/:projectKey/epics', async (req, res, next) => { - try { - const epics = await jiraService.searchEpics( - req.params.instanceId, - req.params.projectKey, - req.query.q || '' - ); - res.json(epics); - } catch (error) { - next(error); - } -}); +router.get('/instances/:instanceId/projects/:projectKey/epics', asyncHandler(async (req, res) => { + const epics = await jiraService.searchEpics( + req.params.instanceId, + req.params.projectKey, + req.query.q || '' + ); + res.json(epics); +})); // ============================================================ // JIRA Status Reports @@ -295,72 +239,56 @@ router.get('/instances/:instanceId/projects/:projectKey/epics', async (req, res, * GET /api/jira/reports * List all JIRA status reports, optionally filtered by appId */ -router.get('/reports', async (req, res, next) => { - try { - const reports = await jiraReports.listReports(req.query.appId || null); - res.json(reports); - } catch (error) { - next(error); - } -}); +router.get('/reports', asyncHandler(async (req, res) => { + const reports = await jiraReports.listReports(req.query.appId || null); + res.json(reports); +})); /** * POST /api/jira/reports/generate * Generate status report for a specific app or all JIRA-enabled apps */ -router.post('/reports/generate', async (req, res, next) => { - try { - const { appId } = req.body; - - if (appId) { - const app = await getAppById(appId); - if (!app) { - throw new ServerError('App not found', { status: 404, code: 'NOT_FOUND' }); - } - if (!app.jira?.enabled) { - throw new ServerError('JIRA is not enabled for this app', { status: 400, code: 'JIRA_NOT_ENABLED' }); - } - const report = await jiraReports.generateReport(appId, app); - res.json(report); - } else { - const reports = await jiraReports.generateAllReports(); - res.json(reports); +router.post('/reports/generate', asyncHandler(async (req, res) => { + const { appId } = req.body; + + if (appId) { + const app = await getAppById(appId); + if (!app) { + throw new ServerError('App not found', { status: 404, code: 'NOT_FOUND' }); + } + if (!app.jira?.enabled) { + throw new ServerError('JIRA is not enabled for this app', { status: 400, code: 'JIRA_NOT_ENABLED' }); } - } catch (error) { - next(error); + const report = await jiraReports.generateReport(appId, app); + res.json(report); + } else { + const reports = await jiraReports.generateAllReports(); + res.json(reports); } -}); +})); /** * GET /api/jira/reports/:appId/latest * Get the latest report for an app */ -router.get('/reports/:appId/latest', async (req, res, next) => { - try { - const report = await jiraReports.getLatestReport(req.params.appId); - if (!report) { - throw new ServerError('No reports found for this app', { status: 404, code: 'NOT_FOUND' }); - } - res.json(report); - } catch (error) { - next(error); +router.get('/reports/:appId/latest', asyncHandler(async (req, res) => { + const report = await jiraReports.getLatestReport(req.params.appId); + if (!report) { + throw new ServerError('No reports found for this app', { status: 404, code: 'NOT_FOUND' }); } -}); + res.json(report); +})); /** * GET /api/jira/reports/:appId/:date * Get a specific report by app and date */ -router.get('/reports/:appId/:date', async (req, res, next) => { - try { - const report = await jiraReports.getReport(req.params.appId, req.params.date); - if (!report) { - throw new ServerError('Report not found', { status: 404, code: 'NOT_FOUND' }); - } - res.json(report); - } catch (error) { - next(error); +router.get('/reports/:appId/:date', asyncHandler(async (req, res) => { + const report = await jiraReports.getReport(req.params.appId, req.params.date); + if (!report) { + throw new ServerError('Report not found', { status: 404, code: 'NOT_FOUND' }); } -}); + res.json(report); +})); export default router; diff --git a/server/routes/lmstudio.js b/server/routes/lmstudio.js index f8ecaff4..c3a90caf 100644 --- a/server/routes/lmstudio.js +++ b/server/routes/lmstudio.js @@ -7,6 +7,7 @@ import { Router } from 'express' import * as lmStudioManager from '../services/lmStudioManager.js' import * as localThinking from '../services/localThinking.js' +import { asyncHandler } from '../lib/errorHandler.js' const router = Router() @@ -14,7 +15,7 @@ const router = Router() * GET /api/lmstudio/status * Check LM Studio availability and loaded models */ -router.get('/status', async (req, res) => { +router.get('/status', asyncHandler(async (req, res) => { const status = await lmStudioManager.getStatus() const thinkingStats = localThinking.getStats() @@ -22,13 +23,13 @@ router.get('/status', async (req, res) => { ...status, thinkingStats }) -}) +})) /** * GET /api/lmstudio/models * List available/loaded models */ -router.get('/models', async (req, res) => { +router.get('/models', asyncHandler(async (req, res) => { const available = await lmStudioManager.checkLMStudioAvailable() if (!available) { @@ -47,13 +48,13 @@ router.get('/models', async (req, res) => { models, recommendedThinkingModel: recommended }) -}) +})) /** * POST /api/lmstudio/download * Download a model by ID */ -router.post('/download', async (req, res) => { +router.post('/download', asyncHandler(async (req, res) => { const { modelId } = req.body if (!modelId) { @@ -62,13 +63,13 @@ router.post('/download', async (req, res) => { const result = await lmStudioManager.downloadModel(modelId) res.json(result) -}) +})) /** * POST /api/lmstudio/load * Load a model into memory */ -router.post('/load', async (req, res) => { +router.post('/load', asyncHandler(async (req, res) => { const { modelId } = req.body if (!modelId) { @@ -77,13 +78,13 @@ router.post('/load', async (req, res) => { const result = await lmStudioManager.loadModel(modelId) res.json(result) -}) +})) /** * POST /api/lmstudio/unload * Unload a model from memory */ -router.post('/unload', async (req, res) => { +router.post('/unload', asyncHandler(async (req, res) => { const { modelId } = req.body if (!modelId) { @@ -92,13 +93,13 @@ router.post('/unload', async (req, res) => { const result = await lmStudioManager.unloadModel(modelId) res.json(result) -}) +})) /** * POST /api/lmstudio/completion * Quick completion using local model */ -router.post('/completion', async (req, res) => { +router.post('/completion', asyncHandler(async (req, res) => { const { prompt, model, maxTokens, temperature, systemPrompt } = req.body if (!prompt) { @@ -113,13 +114,13 @@ router.post('/completion', async (req, res) => { }) res.json(result) -}) +})) /** * POST /api/lmstudio/analyze-task * Analyze a task for complexity and escalation needs */ -router.post('/analyze-task', async (req, res) => { +router.post('/analyze-task', asyncHandler(async (req, res) => { const { description, id, metadata } = req.body if (!description) { @@ -133,13 +134,13 @@ router.post('/analyze-task', async (req, res) => { }) res.json(analysis) -}) +})) /** * POST /api/lmstudio/classify-memory * Classify memory content */ -router.post('/classify-memory', async (req, res) => { +router.post('/classify-memory', asyncHandler(async (req, res) => { const { content } = req.body if (!content) { @@ -148,13 +149,13 @@ router.post('/classify-memory', async (req, res) => { const classification = await localThinking.classifyMemory(content) res.json(classification) -}) +})) /** * POST /api/lmstudio/embeddings * Get embeddings for text */ -router.post('/embeddings', async (req, res) => { +router.post('/embeddings', asyncHandler(async (req, res) => { const { text, model } = req.body if (!text) { @@ -163,13 +164,13 @@ router.post('/embeddings', async (req, res) => { const result = await lmStudioManager.getEmbeddings(text, { model }) res.json(result) -}) +})) /** * PUT /api/lmstudio/config * Update LM Studio configuration */ -router.put('/config', async (req, res) => { +router.put('/config', asyncHandler(async (req, res) => { const { baseUrl, timeout, defaultThinkingModel } = req.body const config = lmStudioManager.updateConfig({ @@ -179,7 +180,7 @@ router.put('/config', async (req, res) => { }) res.json({ success: true, config }) -}) +})) /** * GET /api/lmstudio/thinking-stats diff --git a/server/services/agentPromptBuilder.js b/server/services/agentPromptBuilder.js index a2f0737f..4051b748 100644 --- a/server/services/agentPromptBuilder.js +++ b/server/services/agentPromptBuilder.js @@ -405,7 +405,7 @@ export async function generateJiraTitle(description) { } else { executeApiRun(runId, provider, model, prompt, process.cwd(), [], onData, onDone); } - }).catch(() => {}); + }).catch(err => console.warn(`⚠️ JIRA title generation failed: ${err.message}`)); title = title.trim().replace(/^["']|["']$/g, ''); return title || fallback; diff --git a/server/services/brain.js b/server/services/brain.js index 56e95464..26d7aaeb 100644 --- a/server/services/brain.js +++ b/server/services/brain.js @@ -240,7 +240,7 @@ async function classifyInBackground(entryId, text, meta, providerOverride, model if (validationResult.success) { classification = validationResult.data; } else { - console.error(`🧠 Classification validation failed: ${JSON.stringify(validationResult.error.errors)}`); + console.error(`🧠 Classification validation failed: ${validationResult.error.errors.length} issues, first: ${validationResult.error.errors[0]?.message}`); aiError = new Error('Invalid classification output from AI'); } } else { diff --git a/server/services/character.js b/server/services/character.js index 65673acd..01ccb88f 100644 --- a/server/services/character.js +++ b/server/services/character.js @@ -12,6 +12,9 @@ import * as cosService from './cos.js'; const CHARACTER_FILE = path.join(PATHS.data, 'character.json'); +const BASE_HP = 10; +const HP_PER_LEVEL = 5; + const XP_THRESHOLDS = [ 0, 300, 900, 2700, 6500, 14000, 23000, 34000, 48000, 64000, 85000, 100000, 120000, 140000, 165000, 195000, 225000, 265000, 305000, 355000 @@ -25,7 +28,7 @@ function getLevelFromXP(xp) { } function getMaxHP(level) { - return 10 + (level * 5); + return BASE_HP + (level * HP_PER_LEVEL); } function createEvent(type, description, overrides = {}) { diff --git a/server/services/telegram.js b/server/services/telegram.js index 9d6e202d..9e1c450d 100644 --- a/server/services/telegram.js +++ b/server/services/telegram.js @@ -16,6 +16,8 @@ import { ensureDir, PATHS, readJSONFile, formatDuration } from '../lib/fileUtils import { getActiveAgents } from './subAgentSpawner.js'; import { getGoals } from './identity.js'; +const HEALTH_CHECK_INTERVAL_MS = 30_000; + // Module-level state let bot = null; let isConnected = false; @@ -192,7 +194,7 @@ export async function init(sendTestMessage = false) { }); // Start health check - healthCheckInterval = setInterval(healthCheck, 30000); + healthCheckInterval = setInterval(healthCheck, HEALTH_CHECK_INTERVAL_MS); // Subscribe to notification events initNotificationForwarding();