From 173112827265d518bb96590f8102f00c9942a0ee Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Thu, 9 Apr 2026 21:45:41 -0700 Subject: [PATCH 1/2] dry: better audit remediation --- .../digital-twin/NextActionBanner.jsx | 2 +- .../digital-twin/tabs/DocumentsTab.jsx | 4 +- .../digital-twin/tabs/ExportTab.jsx | 4 +- .../digital-twin/tabs/OverviewTab.jsx | 2 +- .../components/digital-twin/tabs/TestTab.jsx | 4 +- client/src/pages/DigitalTwin.jsx | 4 +- client/src/services/apiDigitalTwin.js | 21 ------ server/lib/execGit.js | 75 +++++++++++++++++++ server/lib/validation.js | 18 +++++ server/routes/calendar.js | 37 +++++---- server/routes/cosScheduleRoutes.js | 23 ------ server/routes/cosScheduleRoutes.test.js | 12 --- server/routes/feeds.js | 7 +- server/routes/messages.js | 39 +++++----- server/services/brainStorage.js | 5 +- server/services/brainSyncLog.js | 4 +- server/services/decisionLog.js | 5 +- server/services/git.js | 69 +---------------- server/services/taskConflict.js | 29 +------ server/services/taskLearning.js | 4 +- server/services/taskTemplates.js | 6 +- server/services/worktreeManager.js | 45 +++-------- 22 files changed, 165 insertions(+), 254 deletions(-) create mode 100644 server/lib/execGit.js diff --git a/client/src/components/digital-twin/NextActionBanner.jsx b/client/src/components/digital-twin/NextActionBanner.jsx index 8c29e6d2e..2799dee05 100644 --- a/client/src/components/digital-twin/NextActionBanner.jsx +++ b/client/src/components/digital-twin/NextActionBanner.jsx @@ -80,7 +80,7 @@ export default function NextActionBanner({ gaps, status, traits, onRefresh }) { const loadQuestion = useCallback(async (category, skipList = []) => { setLoading(true); - const q = await api.getSoulEnrichQuestion(category, undefined, undefined, skipList.length ? skipList : undefined).catch(() => null); + const q = await api.getDigitalTwinEnrichQuestion(category, undefined, undefined, skipList.length ? skipList : undefined).catch(() => null); if (!q) { // Category exhausted — advance to the next gap with available questions setCurrentGapIdx(prev => { diff --git a/client/src/components/digital-twin/tabs/DocumentsTab.jsx b/client/src/components/digital-twin/tabs/DocumentsTab.jsx index 84d030d9b..caa4056b4 100644 --- a/client/src/components/digital-twin/tabs/DocumentsTab.jsx +++ b/client/src/components/digital-twin/tabs/DocumentsTab.jsx @@ -34,13 +34,13 @@ export default function DocumentsTab({ onRefresh }) { const loadDocuments = async () => { setLoading(true); - const docs = await api.getSoulDocuments().catch(() => []); + const docs = await api.getDigitalTwinDocuments().catch(() => []); setDocuments(docs); setLoading(false); }; const loadDocument = async (id) => { - const doc = await api.getSoulDocument(id); + const doc = await api.getDigitalTwinDocument(id); setSelectedDoc(doc); setEditContent(doc.content); setEditMode(false); diff --git a/client/src/components/digital-twin/tabs/ExportTab.jsx b/client/src/components/digital-twin/tabs/ExportTab.jsx index d4ffeec08..3ef0c5465 100644 --- a/client/src/components/digital-twin/tabs/ExportTab.jsx +++ b/client/src/components/digital-twin/tabs/ExportTab.jsx @@ -38,8 +38,8 @@ export default function ExportTab({ onRefresh: _onRefresh }) { const loadData = async () => { setLoading(true); const [docsData, formatsData] = await Promise.all([ - api.getSoulDocuments().catch(() => []), - api.getSoulExportFormats().catch(() => []) + api.getDigitalTwinDocuments().catch(() => []), + api.getDigitalTwinExportFormats().catch(() => []) ]); setDocuments(docsData); setFormats(formatsData); diff --git a/client/src/components/digital-twin/tabs/OverviewTab.jsx b/client/src/components/digital-twin/tabs/OverviewTab.jsx index a436d5cbd..49ed3591a 100644 --- a/client/src/components/digital-twin/tabs/OverviewTab.jsx +++ b/client/src/components/digital-twin/tabs/OverviewTab.jsx @@ -65,7 +65,7 @@ export default function OverviewTab({ status, settings, onRefresh }) { const loadCompleteness = async () => { setLoadingCompleteness(true); - const data = await api.getSoulCompleteness().catch(() => null); + const data = await api.getDigitalTwinCompleteness().catch(() => null); setCompleteness(data); setLoadingCompleteness(false); }; diff --git a/client/src/components/digital-twin/tabs/TestTab.jsx b/client/src/components/digital-twin/tabs/TestTab.jsx index 1e78967e5..bbe83614e 100644 --- a/client/src/components/digital-twin/tabs/TestTab.jsx +++ b/client/src/components/digital-twin/tabs/TestTab.jsx @@ -52,9 +52,9 @@ export default function TestTab({ onRefresh }) { const loadData = async () => { setLoading(true); const [testsData, providersData, historyData, fbStats] = await Promise.all([ - api.getSoulTests().catch(() => []), + api.getDigitalTwinTests().catch(() => []), api.getProviders().catch(() => ({ providers: [] })), - api.getSoulTestHistory(5).catch(() => []), + api.getDigitalTwinTestHistory(5).catch(() => []), api.getBehavioralFeedbackStats().catch(() => null) ]); diff --git a/client/src/pages/DigitalTwin.jsx b/client/src/pages/DigitalTwin.jsx index 008155222..98348864f 100644 --- a/client/src/pages/DigitalTwin.jsx +++ b/client/src/pages/DigitalTwin.jsx @@ -30,8 +30,8 @@ export default function DigitalTwin() { const fetchData = useCallback(async () => { const [statusData, settingsData] = await Promise.all([ - api.getSoulStatus().catch(() => null), - api.getSoulSettings().catch(() => null) + api.getDigitalTwinStatus().catch(() => null), + api.getDigitalTwinSettings().catch(() => null) ]); setStatus(statusData); setSettings(settingsData); diff --git a/client/src/services/apiDigitalTwin.js b/client/src/services/apiDigitalTwin.js index d305ef9d2..74b0b564c 100644 --- a/client/src/services/apiDigitalTwin.js +++ b/client/src/services/apiDigitalTwin.js @@ -2,89 +2,68 @@ import { request } from './apiCore.js'; // Digital Twin - Status & Summary export const getDigitalTwinStatus = () => request('/digital-twin'); -export const getSoulStatus = getDigitalTwinStatus; // Alias for backwards compatibility // Digital Twin - Documents export const getDigitalTwinDocuments = () => request('/digital-twin/documents'); -export const getSoulDocuments = getDigitalTwinDocuments; export const getDigitalTwinDocument = (id) => request(`/digital-twin/documents/${id}`); -export const getSoulDocument = getDigitalTwinDocument; export const createDigitalTwinDocument = (data) => request('/digital-twin/documents', { method: 'POST', body: JSON.stringify(data) }); -export const createSoulDocument = createDigitalTwinDocument; export const updateDigitalTwinDocument = (id, data) => request(`/digital-twin/documents/${id}`, { method: 'PUT', body: JSON.stringify(data) }); -export const updateSoulDocument = updateDigitalTwinDocument; export const deleteDigitalTwinDocument = (id) => request(`/digital-twin/documents/${id}`, { method: 'DELETE' }); -export const deleteSoulDocument = deleteDigitalTwinDocument; // Digital Twin - Testing export const getDigitalTwinTests = () => request('/digital-twin/tests'); -export const getSoulTests = getDigitalTwinTests; export const runDigitalTwinTests = (providerId, model, testIds = null) => request('/digital-twin/tests/run', { method: 'POST', body: JSON.stringify({ providerId, model, testIds }) }); -export const runSoulTests = runDigitalTwinTests; export const runDigitalTwinMultiTests = (providers, testIds = null) => request('/digital-twin/tests/run-multi', { method: 'POST', body: JSON.stringify({ providers, testIds }) }); -export const runSoulMultiTests = runDigitalTwinMultiTests; export const getDigitalTwinTestHistory = (limit = 10) => request(`/digital-twin/tests/history?limit=${limit}`); -export const getSoulTestHistory = getDigitalTwinTestHistory; // Digital Twin - Enrichment export const getDigitalTwinEnrichCategories = () => request('/digital-twin/enrich/categories'); -export const getSoulEnrichCategories = getDigitalTwinEnrichCategories; export const getDigitalTwinEnrichProgress = () => request('/digital-twin/enrich/progress'); -export const getSoulEnrichProgress = getDigitalTwinEnrichProgress; export const getDigitalTwinEnrichQuestion = (category, providerOverride, modelOverride, skipIndices) => request('/digital-twin/enrich/question', { method: 'POST', body: JSON.stringify({ category, providerOverride, modelOverride, ...(skipIndices?.length ? { skipIndices } : {}) }) }); -export const getSoulEnrichQuestion = getDigitalTwinEnrichQuestion; export const submitDigitalTwinEnrichAnswer = (data) => request('/digital-twin/enrich/answer', { method: 'POST', body: JSON.stringify(data) }); -export const submitSoulEnrichAnswer = submitDigitalTwinEnrichAnswer; // Digital Twin - Export export const getDigitalTwinExportFormats = () => request('/digital-twin/export/formats'); -export const getSoulExportFormats = getDigitalTwinExportFormats; export const exportDigitalTwin = (format, documentIds = null, includeDisabled = false) => request('/digital-twin/export', { method: 'POST', body: JSON.stringify({ format, documentIds, includeDisabled }) }); -export const exportSoul = exportDigitalTwin; // Digital Twin - Settings export const getDigitalTwinSettings = () => request('/digital-twin/settings'); -export const getSoulSettings = getDigitalTwinSettings; export const updateDigitalTwinSettings = (settings) => request('/digital-twin/settings', { method: 'PUT', body: JSON.stringify(settings) }); -export const updateSoulSettings = updateDigitalTwinSettings; // Digital Twin - Validation & Analysis export const getDigitalTwinCompleteness = () => request('/digital-twin/validate/completeness'); -export const getSoulCompleteness = getDigitalTwinCompleteness; export const detectDigitalTwinContradictions = (providerId, model) => request('/digital-twin/validate/contradictions', { method: 'POST', body: JSON.stringify({ providerId, model }) }); -export const detectSoulContradictions = detectDigitalTwinContradictions; export const generateDigitalTwinTests = (providerId, model) => request('/digital-twin/tests/generate', { method: 'POST', body: JSON.stringify({ providerId, model }) }); -export const generateSoulTests = generateDigitalTwinTests; export const analyzeWritingSamples = (samples, providerId, model) => request('/digital-twin/analyze-writing', { method: 'POST', body: JSON.stringify({ samples, providerId, model }) diff --git a/server/lib/execGit.js b/server/lib/execGit.js new file mode 100644 index 000000000..23c610f27 --- /dev/null +++ b/server/lib/execGit.js @@ -0,0 +1,75 @@ +/** + * Shared execGit utility — imported by both git.js and worktreeManager.js + * to avoid a circular dependency (git.js imports worktreeManager.js). + */ + +import { spawn } from 'child_process'; + +/** + * Execute a git command safely using spawn (prevents shell injection). + * @param {string[]} args - Git command arguments + * @param {string} cwd - Working directory + * @param {object} options - Additional options + * @param {number} [options.maxBuffer] - Max output buffer size in bytes (default 10 MB) + * @param {number} [options.timeout] - Timeout in ms (default 30s) + * @param {boolean} [options.ignoreExitCode] - Resolve instead of reject on non-zero exit + * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} + */ +export function execGit(args, cwd, options = {}) { + return new Promise((resolve, reject) => { + const maxBuffer = options.maxBuffer || 10 * 1024 * 1024; + const timeout = options.timeout || 30000; + const child = spawn('git', args, { + cwd, + shell: process.platform === 'win32', + windowsHide: true + }); + + let stdout = ''; + let stderr = ''; + let killed = false; + + const timer = setTimeout(() => { + if (!killed) { + killed = true; + child.kill(); + reject(new Error(`git command timed out after ${timeout / 1000}s: git ${args.join(' ')}`)); + } + }, timeout); + + child.stdout.on('data', (data) => { + stdout += data.toString(); + if (stdout.length + stderr.length > maxBuffer && !killed) { + killed = true; + clearTimeout(timer); + child.kill(); + reject(new Error(`git output exceeded maxBuffer (${maxBuffer} bytes)`)); + } + }); + + child.stderr.on('data', (data) => { + stderr += data.toString(); + if (stdout.length + stderr.length > maxBuffer && !killed) { + killed = true; + clearTimeout(timer); + child.kill(); + reject(new Error(`git output exceeded maxBuffer (${maxBuffer} bytes)`)); + } + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (killed) return; + if (code !== 0 && !options.ignoreExitCode) { + reject(new Error(stderr || `git exited with code ${code}`)); + } else { + resolve({ stdout, stderr, exitCode: code }); + } + }); + + child.on('error', (err) => { + clearTimeout(timer); + reject(err); + }); + }); +} diff --git a/server/lib/validation.js b/server/lib/validation.js index 320f4cda3..a5ff4145c 100644 --- a/server/lib/validation.js +++ b/server/lib/validation.js @@ -539,6 +539,24 @@ export function validateRequest(schema, data) { }); } +// ============================================================================= +// PAGINATION HELPERS +// ============================================================================= + +/** + * Parse limit/offset pagination from query params with defaults and clamping. + * @param {object} query - req.query object + * @param {object} options - { defaultLimit, maxLimit } + * @returns {{ limit: number, offset: number }} + */ +export function parsePagination(query, { defaultLimit = 50, maxLimit = 200 } = {}) { + const rawLimit = parseInt(query?.limit, 10); + const rawOffset = parseInt(query?.offset, 10); + const limit = Number.isFinite(rawLimit) && rawLimit > 0 ? Math.min(rawLimit, maxLimit) : defaultLimit; + const offset = Number.isFinite(rawOffset) && rawOffset >= 0 ? rawOffset : 0; + return { limit, offset }; +} + // ============================================================================= // TASK METADATA SANITIZATION // ============================================================================= diff --git a/server/routes/calendar.js b/server/routes/calendar.js index 13b4cbd6b..71b9a0e10 100644 --- a/server/routes/calendar.js +++ b/server/routes/calendar.js @@ -1,7 +1,8 @@ import express from 'express'; import { z } from 'zod'; import { asyncHandler, ServerError } from '../lib/errorHandler.js'; -import { validateRequest } from '../lib/validation.js'; +import { validateRequest, parsePagination } from '../lib/validation.js'; +import { UUID_RE } from '../lib/fileUtils.js'; import * as calendarAccounts from '../services/calendarAccounts.js'; import * as calendarSync from '../services/calendarSync.js'; import * as calendarGoogleSync from '../services/calendarGoogleSync.js'; @@ -94,7 +95,7 @@ router.post('/accounts', asyncHandler(async (req, res) => { })); router.put('/accounts/:id', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const updates = validateRequest(updateAccountSchema, req.body); @@ -105,7 +106,7 @@ router.put('/accounts/:id', asyncHandler(async (req, res) => { })); router.delete('/accounts/:id', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const deleted = await calendarAccounts.deleteAccount(req.params.id); @@ -117,7 +118,7 @@ router.delete('/accounts/:id', asyncHandler(async (req, res) => { // === Sync Routes === router.post('/sync/:accountId', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const io = req.app.get('io'); @@ -127,7 +128,7 @@ router.post('/sync/:accountId', asyncHandler(async (req, res) => { })); router.get('/sync/:accountId/status', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const status = await calendarSync.getSyncStatus(req.params.accountId); @@ -137,15 +138,11 @@ router.get('/sync/:accountId/status', asyncHandler(async (req, res) => { // === Event Routes === router.get('/events', asyncHandler(async (req, res) => { - const { accountId, search, startDate, endDate, limit, offset } = req.query; - if (accountId && !z.string().uuid().safeParse(accountId).success) { + const { accountId, search, startDate, endDate } = req.query; + if (accountId && !UUID_RE.test(accountId)) { return res.status(400).json({ error: 'Invalid accountId format' }); } - let parsedLimit = limit !== undefined ? parseInt(limit, 10) : 50; - if (Number.isNaN(parsedLimit) || parsedLimit <= 0) parsedLimit = 50; - if (parsedLimit > 200) parsedLimit = 200; - let parsedOffset = offset !== undefined ? parseInt(offset, 10) : 0; - if (Number.isNaN(parsedOffset) || parsedOffset < 0) parsedOffset = 0; + const { limit: parsedLimit, offset: parsedOffset } = parsePagination(req.query, { defaultLimit: 50, maxLimit: 200 }); const result = await calendarSync.getEvents({ accountId, search, @@ -158,7 +155,7 @@ router.get('/events', asyncHandler(async (req, res) => { })); router.get('/events/:accountId/:eventId', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { return res.status(400).json({ error: 'Invalid accountId format' }); } const event = await calendarSync.getEvent(req.params.accountId, req.params.eventId); @@ -168,7 +165,7 @@ router.get('/events/:accountId/:eventId', asyncHandler(async (req, res) => { // === Subcalendar Routes === router.get('/accounts/:id/subcalendars', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const account = await calendarAccounts.getAccount(req.params.id); @@ -177,7 +174,7 @@ router.get('/accounts/:id/subcalendars', asyncHandler(async (req, res) => { })); router.put('/accounts/:id/subcalendars', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const { subcalendars } = validateRequest(updateSubcalendarsSchema, req.body); @@ -191,7 +188,7 @@ router.put('/accounts/:id/subcalendars', asyncHandler(async (req, res) => { // === Push Sync Route === router.post('/sync/:accountId/push', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const data = validateRequest(pushSyncSchema, req.body); @@ -202,7 +199,7 @@ router.post('/sync/:accountId/push', asyncHandler(async (req, res) => { // === MCP Discover Calendars Route === router.post('/sync/:accountId/discover', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const io = req.app.get('io'); @@ -214,7 +211,7 @@ router.post('/sync/:accountId/discover', asyncHandler(async (req, res) => { // === MCP Sync Route === router.post('/sync/:accountId/google', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const io = req.app.get('io'); @@ -284,7 +281,7 @@ router.post('/google/auto-configure/run', asyncHandler(async (req, res) => { // === Google API Sync Routes === router.post('/sync/:accountId/api', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const io = req.app.get('io'); @@ -294,7 +291,7 @@ router.post('/sync/:accountId/api', asyncHandler(async (req, res) => { })); router.post('/sync/:accountId/discover-api', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { throw new ServerError('Invalid account ID format', { status: 400, code: 'VALIDATION_ERROR' }); } const result = await calendarGoogleApiSync.apiDiscoverCalendars(req.params.accountId); diff --git a/server/routes/cosScheduleRoutes.js b/server/routes/cosScheduleRoutes.js index 7997c4f24..4cb48b5d3 100644 --- a/server/routes/cosScheduleRoutes.js +++ b/server/routes/cosScheduleRoutes.js @@ -83,29 +83,6 @@ router.put('/schedule/task/:taskType', asyncHandler(async (req, res) => { res.json({ success: true, taskType, interval: result }); })); -// Deprecated aliases — delegate to unified endpoints -router.get('/schedule/self-improvement/:taskType', asyncHandler(async (req, res) => { - const { taskType } = req.params; - const interval = await taskSchedule.getTaskInterval(taskType); - const shouldRun = await taskSchedule.shouldRunTask(taskType); - res.json({ taskType, interval, shouldRun }); -})); -router.put('/schedule/self-improvement/:taskType', asyncHandler(async (req, res) => { - const { taskType } = req.params; - const result = await taskSchedule.updateTaskInterval(taskType, pickScheduleSettings(req.body)); - res.json({ success: true, taskType, interval: result }); -})); -router.get('/schedule/app-improvement/:taskType', asyncHandler(async (req, res) => { - const { taskType } = req.params; - const interval = await taskSchedule.getTaskInterval(taskType); - res.json({ taskType, interval }); -})); -router.put('/schedule/app-improvement/:taskType', asyncHandler(async (req, res) => { - const { taskType } = req.params; - const result = await taskSchedule.updateTaskInterval(taskType, pickScheduleSettings(req.body)); - res.json({ success: true, taskType, interval: result }); -})); - // GET /api/cos/schedule/due - Get all tasks that are due to run router.get('/schedule/due', asyncHandler(async (req, res) => { const tasks = await taskSchedule.getDueTasks(); diff --git a/server/routes/cosScheduleRoutes.test.js b/server/routes/cosScheduleRoutes.test.js index d8909a62d..98f86e483 100644 --- a/server/routes/cosScheduleRoutes.test.js +++ b/server/routes/cosScheduleRoutes.test.js @@ -306,16 +306,4 @@ describe('CoS Schedule Routes', () => { }); }); - // Deprecated alias routes - describe('GET /api/cos/schedule/self-improvement/:taskType', () => { - it('should delegate to unified endpoint', async () => { - taskSchedule.getTaskInterval.mockResolvedValue({ type: 'daily' }); - taskSchedule.shouldRunTask.mockResolvedValue(false); - - const response = await request(app).get('/api/cos/schedule/self-improvement/review'); - - expect(response.status).toBe(200); - expect(response.body.taskType).toBe('review'); - }); - }); }); diff --git a/server/routes/feeds.js b/server/routes/feeds.js index abbf8b317..a21bb25d3 100644 --- a/server/routes/feeds.js +++ b/server/routes/feeds.js @@ -16,7 +16,7 @@ import { Router } from 'express'; import { z } from 'zod'; import { asyncHandler } from '../lib/errorHandler.js'; -import { validateRequest } from '../lib/validation.js'; +import { validateRequest, parsePagination } from '../lib/validation.js'; import * as feedsService from '../services/feeds.js'; const router = Router(); @@ -37,14 +37,15 @@ router.get('/stats', asyncHandler(async (req, res) => { res.json(stats); })); -// GET /api/feeds/items — get feed items +// GET /api/feeds/items — get feed items (supports limit/offset pagination, default limit 100) router.get('/items', asyncHandler(async (req, res) => { const { feedId, unreadOnly } = req.query; + const { limit, offset } = parsePagination(req.query, { defaultLimit: 100, maxLimit: 500 }); const items = await feedsService.getItems({ feedId: feedId || undefined, unreadOnly: unreadOnly === 'true' }); - res.json(items); + res.json(items.slice(offset, offset + limit)); })); // POST /api/feeds — add a new feed subscription diff --git a/server/routes/messages.js b/server/routes/messages.js index b3063a2ea..c29b3c2ea 100644 --- a/server/routes/messages.js +++ b/server/routes/messages.js @@ -1,7 +1,8 @@ import express from 'express'; import { z } from 'zod'; import { asyncHandler } from '../lib/errorHandler.js'; -import { validateRequest } from '../lib/validation.js'; +import { validateRequest, parsePagination } from '../lib/validation.js'; +import { UUID_RE } from '../lib/fileUtils.js'; import * as messageAccounts from '../services/messageAccounts.js'; import * as messageSync from '../services/messageSync.js'; import * as messageDrafts from '../services/messageDrafts.js'; @@ -84,7 +85,7 @@ router.post('/accounts', asyncHandler(async (req, res) => { })); router.put('/accounts/:id', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid account ID format' }); } const updates = validateRequest(updateAccountSchema, req.body); @@ -95,7 +96,7 @@ router.put('/accounts/:id', asyncHandler(async (req, res) => { })); router.delete('/accounts/:id', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid account ID format' }); } const deleted = await messageAccounts.deleteAccount(req.params.id); @@ -109,7 +110,7 @@ router.delete('/accounts/:id', asyncHandler(async (req, res) => { // === Sync Routes === router.post('/sync/:accountId', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { return res.status(400).json({ error: 'Invalid account ID format' }); } const mode = ['unread', 'full'].includes(req.body?.mode) ? req.body.mode : 'unread'; @@ -120,7 +121,7 @@ router.post('/sync/:accountId', asyncHandler(async (req, res) => { })); router.get('/sync/:accountId/status', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { return res.status(400).json({ error: 'Invalid account ID format' }); } const status = await messageSync.getSyncStatus(req.params.accountId); @@ -130,15 +131,11 @@ router.get('/sync/:accountId/status', asyncHandler(async (req, res) => { // === Inbox Routes === router.get('/inbox', asyncHandler(async (req, res) => { - const { accountId, search, limit, offset } = req.query; - if (accountId && !z.string().uuid().safeParse(accountId).success) { + const { accountId, search } = req.query; + if (accountId && !UUID_RE.test(accountId)) { return res.status(400).json({ error: 'Invalid accountId format' }); } - let parsedLimit = limit !== undefined ? parseInt(limit, 10) : 50; - if (Number.isNaN(parsedLimit) || parsedLimit <= 0) parsedLimit = 50; - if (parsedLimit > 100) parsedLimit = 100; - let parsedOffset = offset !== undefined ? parseInt(offset, 10) : 0; - if (Number.isNaN(parsedOffset) || parsedOffset < 0) parsedOffset = 0; + const { limit: parsedLimit, offset: parsedOffset } = parsePagination(req.query, { defaultLimit: 50, maxLimit: 100 }); const result = await messageSync.getMessages({ accountId, search, @@ -189,7 +186,7 @@ router.post('/evaluate', asyncHandler(async (req, res) => { // === Draft Routes === router.get('/drafts', asyncHandler(async (req, res) => { const { accountId, status } = req.query; - if (accountId && !z.string().uuid().safeParse(accountId).success) { + if (accountId && !UUID_RE.test(accountId)) { return res.status(400).json({ error: 'Invalid accountId format' }); } const drafts = await messageDrafts.listDrafts({ accountId, status }); @@ -253,7 +250,7 @@ router.post('/drafts/generate', asyncHandler(async (req, res) => { })); router.put('/drafts/:id', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid draft ID format' }); } const updates = validateRequest(updateDraftSchema, req.body); @@ -263,7 +260,7 @@ router.put('/drafts/:id', asyncHandler(async (req, res) => { })); router.post('/drafts/:id/approve', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid draft ID format' }); } const draft = await messageDrafts.approveDraft(req.params.id); @@ -272,7 +269,7 @@ router.post('/drafts/:id/approve', asyncHandler(async (req, res) => { })); router.post('/drafts/:id/send', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid draft ID format' }); } const io = req.app.get('io'); @@ -284,7 +281,7 @@ router.post('/drafts/:id/send', asyncHandler(async (req, res) => { })); router.delete('/drafts/:id', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid draft ID format' }); } const deleted = await messageDrafts.deleteDraft(req.params.id); @@ -294,7 +291,7 @@ router.delete('/drafts/:id', asyncHandler(async (req, res) => { // === Browser Launch Route === router.post('/launch/:accountId', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { return res.status(400).json({ error: 'Invalid account ID format' }); } const account = await messageAccounts.getAccount(req.params.accountId); @@ -357,7 +354,7 @@ router.post('/selectors/:provider/test', asyncHandler(async (req, res) => { // === Thread Route === router.get('/thread/:accountId/:threadId', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { return res.status(400).json({ error: 'Invalid accountId format' }); } if (!req.params.threadId) return res.status(400).json({ error: 'threadId is required' }); @@ -389,7 +386,7 @@ router.post('/:accountId/:messageId/refresh', asyncHandler(async (req, res) => { // === Fetch full content for preview-only messages === router.post('/fetch-full/:accountId', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.accountId).success) { + if (!UUID_RE.test(req.params.accountId)) { return res.status(400).json({ error: 'Invalid account ID format' }); } const { accountId } = req.params; @@ -413,7 +410,7 @@ router.post('/fetch-full/:accountId', asyncHandler(async (req, res) => { // === Clear account cache === router.post('/accounts/:id/cache/clear', asyncHandler(async (req, res) => { - if (!z.string().uuid().safeParse(req.params.id).success) { + if (!UUID_RE.test(req.params.id)) { return res.status(400).json({ error: 'Invalid account ID format' }); } await messageSync.deleteCache(req.params.id); diff --git a/server/services/brainStorage.js b/server/services/brainStorage.js index f78a9a75f..34d453de6 100644 --- a/server/services/brainStorage.js +++ b/server/services/brainStorage.js @@ -71,10 +71,7 @@ const DEFAULT_META = { * Ensure brain data directory exists */ export async function ensureBrainDir() { - if (!existsSync(DATA_DIR)) { - await ensureDir(DATA_DIR); - console.log(`🧠 Created brain data directory: ${DATA_DIR}`); - } + await ensureDir(DATA_DIR); } /** diff --git a/server/services/brainSyncLog.js b/server/services/brainSyncLog.js index 9e4abe45e..cc52eb2e3 100644 --- a/server/services/brainSyncLog.js +++ b/server/services/brainSyncLog.js @@ -18,9 +18,7 @@ const withLock = createMutex(); let currentSeq = 0; async function ensureBrainDir() { - if (!existsSync(DATA_DIR)) { - await ensureDir(DATA_DIR); - } + await ensureDir(DATA_DIR); } /** diff --git a/server/services/decisionLog.js b/server/services/decisionLog.js index 06743c8a4..8844f5f4c 100644 --- a/server/services/decisionLog.js +++ b/server/services/decisionLog.js @@ -7,7 +7,6 @@ */ import { writeFile } from 'fs/promises'; -import { existsSync } from 'fs'; import { join } from 'path'; import { ensureDir, readJSONFile, PATHS } from '../lib/fileUtils.js'; import { cosEvents } from './cosEvents.js'; @@ -54,9 +53,7 @@ async function loadDecisions() { return decisionCache; } - if (!existsSync(DATA_DIR)) { - await ensureDir(DATA_DIR); - } + await ensureDir(DATA_DIR); decisionCache = await readJSONFile(DECISION_FILE, { ...DEFAULT_DATA }); cacheLoaded = true; diff --git a/server/services/git.js b/server/services/git.js index cf8557f3f..594ea030f 100644 --- a/server/services/git.js +++ b/server/services/git.js @@ -1,77 +1,12 @@ -import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; import { safeJSONParse, PATHS } from '../lib/fileUtils.js'; import { listWorktrees } from './worktreeManager.js'; +export { execGit } from '../lib/execGit.js'; +import { execGit } from '../lib/execGit.js'; const PROTECTED_BRANCHES = ['main', 'master', 'dev', 'develop', 'release']; -/** - * Execute a git command safely using spawn (prevents shell injection) - * @param {string[]} args - Git command arguments - * @param {string} cwd - Working directory - * @param {object} options - Additional options - * @returns {Promise<{stdout: string, stderr: string, exitCode: number}>} - */ -function execGit(args, cwd, options = {}) { - return new Promise((resolve, reject) => { - const maxBuffer = options.maxBuffer || 10 * 1024 * 1024; - const timeout = options.timeout || 30000; - const child = spawn('git', args, { - cwd, - shell: process.platform === 'win32', - windowsHide: true - }); - - let stdout = ''; - let stderr = ''; - let killed = false; - - const timer = setTimeout(() => { - if (!killed) { - killed = true; - child.kill(); - reject(new Error(`git command timed out after ${timeout / 1000}s: git ${args.join(' ')}`)); - } - }, timeout); - - child.stdout.on('data', (data) => { - stdout += data.toString(); - if (stdout.length + stderr.length > maxBuffer && !killed) { - killed = true; - clearTimeout(timer); - child.kill(); - reject(new Error(`git output exceeded maxBuffer (${maxBuffer} bytes)`)); - } - }); - - child.stderr.on('data', (data) => { - stderr += data.toString(); - if (stdout.length + stderr.length > maxBuffer && !killed) { - killed = true; - clearTimeout(timer); - child.kill(); - reject(new Error(`git output exceeded maxBuffer (${maxBuffer} bytes)`)); - } - }); - - child.on('close', (code) => { - clearTimeout(timer); - if (killed) return; - if (code !== 0 && !options.ignoreExitCode) { - reject(new Error(stderr || `git exited with code ${code}`)); - } else { - resolve({ stdout, stderr, exitCode: code }); - } - }); - - child.on('error', (err) => { - clearTimeout(timer); - reject(err); - }); - }); -} - // Like execGit but catches rejections (e.g. timeout) into a failed-result shape const execGitSafe = (args, cwd, options) => execGit(args, cwd, options).catch(err => ({ exitCode: 1, stdout: '', stderr: err.message })); diff --git a/server/services/taskConflict.js b/server/services/taskConflict.js index 603fc251d..b8fe8344f 100644 --- a/server/services/taskConflict.js +++ b/server/services/taskConflict.js @@ -6,36 +6,15 @@ * use a git worktree for isolation. */ -import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { join } from 'path'; - -/** - * Execute a git command and return stdout - */ -function execGit(args, cwd) { - return new Promise((resolve, reject) => { - const child = spawn('git', args, { cwd, shell: false, windowsHide: true }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', d => { stdout += d.toString(); }); - child.stderr.on('data', d => { stderr += d.toString(); }); - child.on('close', code => { - if (code !== 0) { - reject(new Error(stderr || `git exited with code ${code}`)); - } else { - resolve(stdout); - } - }); - child.on('error', reject); - }); -} +import { execGit } from '../lib/execGit.js'; /** * Get list of files modified in the working tree (unstaged + staged + untracked) */ async function getModifiedFiles(workspacePath) { - const stdout = await execGit(['status', '--porcelain'], workspacePath); + const { stdout } = await execGit(['status', '--porcelain'], workspacePath); return stdout.trim().split('\n') .filter(Boolean) .map(line => line.substring(3).trim()); @@ -46,10 +25,10 @@ async function getModifiedFiles(workspacePath) { */ async function isGitRepo(workspacePath) { if (!existsSync(join(workspacePath, '.git'))) return false; - const stdout = await execGit( + const { stdout } = await execGit( ['rev-parse', '--is-inside-work-tree'], workspacePath - ).catch(() => 'false'); + ).catch(() => ({ stdout: 'false' })); return stdout.trim() === 'true'; } diff --git a/server/services/taskLearning.js b/server/services/taskLearning.js index d70bb9f11..6b7170f82 100644 --- a/server/services/taskLearning.js +++ b/server/services/taskLearning.js @@ -86,9 +86,7 @@ async function loadLearningData() { return structuredClone(_learningCache); } - if (!existsSync(DATA_DIR)) { - await ensureDir(DATA_DIR); - } + await ensureDir(DATA_DIR); const data = await readJSONFile(LEARNING_FILE, structuredClone(DEFAULT_LEARNING_DATA)); _learningCache = structuredClone(data); diff --git a/server/services/taskTemplates.js b/server/services/taskTemplates.js index 80dd46ac3..dd14d6a52 100644 --- a/server/services/taskTemplates.js +++ b/server/services/taskTemplates.js @@ -6,7 +6,6 @@ */ import { writeFile } from 'fs/promises'; -import { existsSync } from 'fs'; import { join } from 'path'; import { ensureDir, readJSONFile, PATHS } from '../lib/fileUtils.js'; @@ -112,10 +111,7 @@ async function loadState() { async function saveState(state) { state.lastUpdated = new Date().toISOString(); - if (!existsSync(DATA_DIR)) { - await ensureDir(DATA_DIR); - } - + await ensureDir(DATA_DIR); await writeFile(TEMPLATES_FILE, JSON.stringify(state, null, 2)); } diff --git a/server/services/worktreeManager.js b/server/services/worktreeManager.js index d15c52198..8772cfb78 100644 --- a/server/services/worktreeManager.js +++ b/server/services/worktreeManager.js @@ -9,37 +9,16 @@ * and the branch cleaned up. */ -import { spawn } from 'child_process'; import { existsSync } from 'fs'; import { readdir, readFile, rm, stat } from 'fs/promises'; import { join } from 'path'; import { ensureDir, PATHS } from '../lib/fileUtils.js'; +import { execGit } from '../lib/execGit.js'; const WORKTREES_DIR = PATHS.worktrees; // Lockfiles that npm/yarn/pnpm modify as a side-effect — safe to discard during worktree cleanup const AUTO_GENERATED_LOCKFILES = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']; -/** - * Execute a git command and return stdout - */ -function execGit(args, cwd) { - return new Promise((resolve, reject) => { - const child = spawn('git', args, { cwd, shell: false, windowsHide: true }); - let stdout = ''; - let stderr = ''; - child.stdout.on('data', d => { stdout += d.toString(); }); - child.stderr.on('data', d => { stderr += d.toString(); }); - child.on('close', code => { - if (code !== 0) { - reject(new Error(stderr || `git exited with code ${code}`)); - } else { - resolve(stdout); - } - }); - child.on('error', reject); - }); -} - /** * Create a git worktree for an agent. * @@ -72,11 +51,11 @@ export async function createWorktree(agentId, sourceWorkspace, taskId, options = // Determine the base: explicit option > detected default branch > current HEAD let baseBranch = options.baseBranch; if (!baseBranch) { - const mainExists = (await execGit(['branch', '--list', 'main'], sourceWorkspace)).trim(); - const masterExists = (await execGit(['branch', '--list', 'master'], sourceWorkspace)).trim(); + const mainExists = (await execGit(['branch', '--list', 'main'], sourceWorkspace)).stdout.trim(); + const masterExists = (await execGit(['branch', '--list', 'master'], sourceWorkspace)).stdout.trim(); if (mainExists) baseBranch = 'main'; else if (masterExists) baseBranch = 'master'; - else baseBranch = (await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], sourceWorkspace)).trim(); + else baseBranch = (await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], sourceWorkspace)).stdout.trim(); } // Prefer the remote ref (freshest state) if available @@ -122,7 +101,7 @@ export async function removeWorktree(agentId, sourceWorkspace, branchName, optio // In that case, git status would report the parent repo's dirty files, causing us to // incorrectly preserve the worktree. const detectedToplevel = await execGit(['rev-parse', '--show-toplevel'], worktreePath) - .then(s => s.trim()) + .then(r => r.stdout.trim()) .catch(() => null); if (detectedToplevel && detectedToplevel !== worktreePath) { console.log(`🌳 Worktree ${agentId} resolves to ${detectedToplevel} instead of ${worktreePath} — broken worktree, removing`); @@ -137,7 +116,7 @@ export async function removeWorktree(agentId, sourceWorkspace, branchName, optio // Also fail closed if git status itself fails — treat unknown state as dirty. let dirtyFiles; try { - dirtyFiles = (await execGit(['status', '--porcelain'], worktreePath)).trim(); + dirtyFiles = (await execGit(['status', '--porcelain'], worktreePath)).stdout.trim(); } catch (err) { console.log(`⚠️ git status failed for worktree ${agentId}, preserving to avoid data loss: ${err.message}`); warnings.push(`Worktree preserved — git status failed: ${err.message}`); @@ -169,12 +148,12 @@ export async function removeWorktree(agentId, sourceWorkspace, branchName, optio const currentBranch = (await execGit( ['rev-parse', '--abbrev-ref', 'HEAD'], sourceWorkspace - )).trim(); + )).stdout.trim(); commitsAhead = parseInt((await execGit( ['rev-list', '--count', `${currentBranch}..${branchName}`], sourceWorkspace - ).catch(() => '0')).trim(), 10) || 0; + ).catch(() => ({ stdout: '0' }))).stdout.trim(), 10) || 0; if (commitsAhead > 0) { await execGit(['merge', branchName, '--no-edit'], sourceWorkspace) @@ -232,8 +211,8 @@ export async function createPersistentWorktree(featureAgentId, sourceWorkspace, .catch(() => baseBranch); // Check if branch already exists (local or remote) - const localBranchExists = (await execGit(['branch', '--list', branchName], sourceWorkspace)).trim(); - const remoteBranchExists = (await execGit(['branch', '-r', '--list', `origin/${branchName}`], sourceWorkspace)).trim(); + const localBranchExists = (await execGit(['branch', '--list', branchName], sourceWorkspace)).stdout.trim(); + const remoteBranchExists = (await execGit(['branch', '-r', '--list', `origin/${branchName}`], sourceWorkspace)).stdout.trim(); if (localBranchExists) { // Local branch exists - create worktree from existing branch @@ -306,7 +285,7 @@ export async function mergeBaseIntoFeatureWorktree(featureAgentId, baseBranch = * List all active worktrees for the repository */ export async function listWorktrees(sourceWorkspace) { - const stdout = await execGit(['worktree', 'list', '--porcelain'], sourceWorkspace); + const { stdout } = await execGit(['worktree', 'list', '--porcelain'], sourceWorkspace); const worktrees = []; let current = {}; @@ -421,7 +400,7 @@ async function cleanupExternalRepoWorktrees(activeAgentIds, alreadyHandled) { // Clean via the parent repo's git console.log(`🌳 Cleaning external worktree ${agentId} from ${parentRepo}`); const branchName = await execGit(['rev-parse', '--abbrev-ref', 'HEAD'], worktreePath) - .then(b => b.trim()) + .then(r => r.stdout.trim()) .catch(() => ''); await execGit(['worktree', 'remove', worktreePath, '--force'], parentRepo) From de9b8afc53c6f985af371778de0b7d14c92acb33 Mon Sep 17 00:00:00 2001 From: Adam Eivy Date: Fri, 10 Apr 2026 06:58:02 -0700 Subject: [PATCH 2/2] fix: address review: update remaining getSoul call sites in EnrichTab --- client/src/components/digital-twin/tabs/EnrichTab.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/src/components/digital-twin/tabs/EnrichTab.jsx b/client/src/components/digital-twin/tabs/EnrichTab.jsx index 4c5e9d89b..069d80a37 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); }, []); @@ -121,7 +121,7 @@ export default function EnrichTab({ onRefresh }) { content: writingAnalysis.suggestedContent }).catch(async () => { // Document might exist, try to update by fetching ID - const docs = await api.getSoulDocuments(); + const docs = await api.getDigitalTwinDocuments(); const existing = docs.find(d => d.filename === 'WRITING_STYLE.md'); if (existing) { return api.updateSoulDocument(existing.id, { @@ -137,7 +137,7 @@ 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);