fix: replace console.* with structured pino logger, fix ESLint errors in k6 load tests

This commit is contained in:
Brad Groux 2026-01-29 07:35:48 -06:00
parent 76b93ebe85
commit 8c892d7c22
32 changed files with 406 additions and 296 deletions

View file

@ -51,6 +51,19 @@ export default [
},
},
// k6 load-test files (ES module syntax, k6 globals)
{
files: ['load-tests/**/*.js'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
globals: {
__ENV: 'readonly',
console: 'readonly',
},
},
},
// React/TypeScript files (web)
{
files: ['web/src/**/*.tsx', 'web/src/**/*.ts'],

View file

@ -31,7 +31,11 @@ describe('ConfigService', () => {
describe('getConfig', () => {
it('should return default config when no file exists', async () => {
// Remove the file if it exists
try { await fs.unlink(configFile); } catch {}
try {
await fs.unlink(configFile);
} catch {
/* file may not exist */
}
const config = await service.getConfig();
expect(config).toBeDefined();
expect(config.repos).toEqual([]);
@ -41,7 +45,10 @@ describe('ConfigService', () => {
it('should create config file with defaults when missing', async () => {
await service.getConfig();
const exists = await fs.access(configFile).then(() => true).catch(() => false);
const exists = await fs
.access(configFile)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
});
@ -67,11 +74,14 @@ describe('ConfigService', () => {
it('should merge feature defaults for backward compat', async () => {
// Write config without features
await fs.writeFile(configFile, JSON.stringify({
repos: [],
agents: [],
defaultAgent: 'claude-code',
}));
await fs.writeFile(
configFile,
JSON.stringify({
repos: [],
agents: [],
defaultAgent: 'claude-code',
})
);
const config = await service.getConfig();
expect(config.features).toBeDefined();
@ -99,7 +109,10 @@ describe('ConfigService', () => {
defaultAgent: 'claude-code',
} as any);
const exists = await fs.access(newFile).then(() => true).catch(() => false);
const exists = await fs
.access(newFile)
.then(() => true)
.catch(() => false);
expect(exists).toBe(true);
newService.dispose();
});
@ -123,10 +136,13 @@ describe('ConfigService', () => {
describe('addRepo', () => {
it('should reject duplicate repo names', async () => {
// Write a config with one repo
await fs.writeFile(configFile, JSON.stringify({
repos: [{ name: 'existing', path: '/tmp' }],
agents: [],
}));
await fs.writeFile(
configFile,
JSON.stringify({
repos: [{ name: 'existing', path: '/tmp' }],
agents: [],
})
);
await expect(
service.addRepo({ name: 'existing', path: '/tmp/other' } as any)
@ -136,26 +152,32 @@ describe('ConfigService', () => {
describe('updateRepo', () => {
it('should reject update for non-existent repo', async () => {
await fs.writeFile(configFile, JSON.stringify({
repos: [],
agents: [],
}));
await fs.writeFile(
configFile,
JSON.stringify({
repos: [],
agents: [],
})
);
await expect(
service.updateRepo('nonexistent', { path: '/new/path' })
).rejects.toThrow('not found');
await expect(service.updateRepo('nonexistent', { path: '/new/path' })).rejects.toThrow(
'not found'
);
});
});
describe('removeRepo', () => {
it('should remove existing repo', async () => {
await fs.writeFile(configFile, JSON.stringify({
repos: [
{ name: 'keep', path: '/tmp/keep' },
{ name: 'remove', path: '/tmp/remove' },
],
agents: [],
}));
await fs.writeFile(
configFile,
JSON.stringify({
repos: [
{ name: 'keep', path: '/tmp/keep' },
{ name: 'remove', path: '/tmp/remove' },
],
agents: [],
})
);
const config = await service.removeRepo('remove');
expect(config.repos).toHaveLength(1);
@ -163,10 +185,13 @@ describe('ConfigService', () => {
});
it('should reject removal of non-existent repo', async () => {
await fs.writeFile(configFile, JSON.stringify({
repos: [],
agents: [],
}));
await fs.writeFile(
configFile,
JSON.stringify({
repos: [],
agents: [],
})
);
await expect(service.removeRepo('ghost')).rejects.toThrow('not found');
});
@ -212,11 +237,14 @@ describe('ConfigService', () => {
it('should safely handle config files without prototype pollution', async () => {
// JSON.parse produces a plain object even with __proto__ key
// The deep merge function has defense-in-depth checks
await fs.writeFile(configFile, JSON.stringify({
repos: [],
agents: [],
defaultAgent: 'claude-code',
}));
await fs.writeFile(
configFile,
JSON.stringify({
repos: [],
agents: [],
defaultAgent: 'claude-code',
})
);
const config = await service.getConfig();
// Verify no prototype pollution occurred

View file

@ -1,6 +1,8 @@
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { createLogger } from '../lib/logger.js';
const log = createLogger('security');
// Security config file location
const DATA_DIR = process.env.VERITAS_DATA_DIR || path.join(process.cwd(), '.veritas-kanban');
@ -71,7 +73,7 @@ export function getSecurityConfig(): SecurityConfig {
return cachedConfig!;
}
} catch (err) {
console.error('Error loading security config:', err);
log.error({ err: err }, 'Error loading security config');
}
// Default config
@ -113,7 +115,7 @@ export function getJwtSecret(): string {
// 4. Runtime-generated (ephemeral — sessions won't survive restart)
if (!runtimeJwtSecret) {
runtimeJwtSecret = crypto.randomBytes(64).toString('hex');
console.warn(
log.warn(
'JWT secret generated at runtime. Set VERITAS_JWT_SECRET env var for persistence across restarts.'
);
}
@ -233,9 +235,7 @@ export function rotateJwtSecret(gracePeriodMs: number = SECRET_GRACE_PERIOD_MS):
};
saveSecurityConfig(updatedConfig);
console.log(
`JWT secret rotated to version ${newVersion}. ${prunedCount} expired secret(s) pruned.`
);
log.info(`JWT secret rotated to version ${newVersion}. ${prunedCount} expired secret(s) pruned.`);
return {
success: true,
@ -305,9 +305,9 @@ export function saveSecurityConfig(config: SecurityConfig): void {
cachedConfig = config;
lastLoadTime = Date.now();
console.log('Security config saved');
log.info('Security config saved');
} catch (err) {
console.error('Error saving security config:', err);
log.error({ err: err }, 'Error saving security config');
throw err;
}
}
@ -353,7 +353,7 @@ export function resetSecurityConfig(): void {
};
saveSecurityConfig(newConfig);
runtimeJwtSecret = null;
console.log('Security config reset. Next load will show setup screen.');
log.info('Security config reset. Next load will show setup screen.');
}
/** Warning about JWT secret configuration */

View file

@ -9,6 +9,8 @@ import {
statusHistoryService,
type AgentStatusState as HistoryStatusState,
} from '../services/status-history-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('agent-status');
const router: RouterType = Router();
@ -41,14 +43,14 @@ function loadPersistedStatus(): AgentStatus {
const parsed = JSON.parse(raw) as AgentStatus;
// Validate it has the expected shape
if (parsed.status && typeof parsed.subAgentCount === 'number') {
console.log(
log.info(
`[AgentStatus] Restored persisted status: ${parsed.status} (subAgents: ${parsed.subAgentCount})`
);
return parsed;
}
}
} catch {
console.warn('[AgentStatus] Could not load persisted status, starting fresh');
log.warn('[AgentStatus] Could not load persisted status, starting fresh');
}
return {
status: 'idle',
@ -68,7 +70,7 @@ function persistStatus(status: AgentStatus): void {
}
fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2), 'utf-8');
} catch (err) {
console.warn('[AgentStatus] Failed to persist status:', err);
log.warn({ data: err }, '[AgentStatus] Failed to persist status');
}
}
@ -130,7 +132,7 @@ function resetIdleTimeout(): void {
};
persistStatus(currentStatus);
broadcastAgentStatusChange();
console.log('[AgentStatus] Auto-reset to idle after timeout');
log.info('[AgentStatus] Auto-reset to idle after timeout');
}, IDLE_TIMEOUT_MS);
}
@ -157,7 +159,7 @@ export function updateAgentStatus(update: Partial<AgentStatus>): AgentStatus {
update.subAgentCount ?? currentStatus.subAgentCount
)
.catch((err) => {
console.error('[AgentStatus] Failed to log status change:', err);
log.error({ err: err }, '[AgentStatus] Failed to log status change');
});
}

View file

@ -6,6 +6,8 @@ import { TaskService } from '../services/task-service.js';
import { getAttachmentService } from '../services/attachment-service.js';
import { getTextExtractionService } from '../services/text-extraction-service.js';
import type { Attachment } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('attachments');
const router: RouterType = Router();
const taskService = new TaskService();
@ -47,15 +49,17 @@ router.post('/:id/attachments', upload.array('files', 20), async (req: Request,
for (const file of files) {
try {
// Save attachment (includes magic-byte MIME validation)
const attachment = await attachmentService.saveAttachment(
taskId,
file,
[...currentAttachments, ...newAttachments]
);
const attachment = await attachmentService.saveAttachment(taskId, file, [
...currentAttachments,
...newAttachments,
]);
// Extract text using the validated MIME type
const filepath = attachmentService.getAttachmentPath(taskId, attachment.filename);
const extractedText = await textExtractionService.extractText(filepath, attachment.mimeType);
const extractedText = await textExtractionService.extractText(
filepath,
attachment.mimeType
);
// Save extracted text if available
if (extractedText) {
@ -65,7 +69,7 @@ router.post('/:id/attachments', upload.array('files', 20), async (req: Request,
newAttachments.push(attachment);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`Rejected file "${file.originalname}":`, message);
log.error({ err: message }, `Rejected file "${file.originalname}"`);
rejectedFiles.push({ filename: file.originalname, error: message });
// Continue with other files
}
@ -92,7 +96,7 @@ router.post('/:id/attachments', upload.array('files', 20), async (req: Request,
...(rejectedFiles.length > 0 && { rejected: rejectedFiles }),
});
} catch (error) {
console.error('Upload error:', error);
log.error({ err: error }, 'Upload error');
res.status(500).json({ error: 'Failed to upload attachments' });
}
});
@ -112,7 +116,7 @@ router.get('/:id/attachments', async (req: Request, res: Response) => {
res.json(task.attachments || []);
} catch (error) {
console.error('List attachments error:', error);
log.error({ err: error }, 'List attachments error');
res.status(500).json({ error: 'Failed to list attachments' });
}
});
@ -138,7 +142,7 @@ router.get('/:id/attachments/:attId', async (req: Request, res: Response) => {
res.json(attachment);
} catch (error) {
console.error('Get attachment error:', error);
log.error({ err: error }, 'Get attachment error');
res.status(500).json({ error: 'Failed to get attachment' });
}
});
@ -163,12 +167,15 @@ router.get('/:id/attachments/:attId/download', async (req: Request, res: Respons
}
const filepath = attachmentService.getAttachmentPath(taskId, attachment.filename);
res.setHeader('Content-Type', attachment.mimeType);
res.setHeader('Content-Disposition', contentDisposition(attachment.originalName, { type: 'attachment' }));
res.setHeader(
'Content-Disposition',
contentDisposition(attachment.originalName, { type: 'attachment' })
);
res.sendFile(filepath);
} catch (error) {
console.error('Download error:', error);
log.error({ err: error }, 'Download error');
res.status(500).json({ error: 'Failed to download attachment' });
}
});
@ -193,14 +200,14 @@ router.get('/:id/attachments/:attId/text', async (req: Request, res: Response) =
}
const text = await attachmentService.getExtractedText(taskId, attId);
res.json({
attachmentId: attId,
text,
hasText: text !== null,
});
} catch (error) {
console.error('Get text error:', error);
log.error({ err: error }, 'Get text error');
res.status(500).json({ error: 'Failed to get extracted text' });
}
});
@ -235,7 +242,7 @@ router.delete('/:id/attachments/:attId', async (req: Request, res: Response) =>
res.json({ success: true });
} catch (error) {
console.error('Delete attachment error:', error);
log.error({ err: error }, 'Delete attachment error');
res.status(500).json({ error: 'Failed to delete attachment' });
}
});

View file

@ -2,6 +2,8 @@ import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { ConfigService } from '../services/config-service.js';
import type { RepoConfig, AgentConfig, AgentType } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('config');
const router: RouterType = Router();
const configService = new ConfigService();
@ -39,7 +41,7 @@ router.get('/', async (_req, res) => {
const config = await configService.getConfig();
res.json(config);
} catch (error) {
console.error('Error getting config:', error);
log.error({ err: error }, 'Error getting config');
res.status(500).json({ error: 'Failed to get config' });
}
});
@ -50,7 +52,7 @@ router.get('/repos', async (_req, res) => {
const config = await configService.getConfig();
res.json(config.repos);
} catch (error) {
console.error('Error listing repos:', error);
log.error({ err: error }, 'Error listing repos');
res.status(500).json({ error: 'Failed to list repos' });
}
});
@ -65,7 +67,7 @@ router.post('/repos', async (req, res) => {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error adding repo:', error);
log.error({ err: error }, 'Error adding repo');
res.status(400).json({ error: error.message || 'Failed to add repo' });
}
});
@ -80,7 +82,7 @@ router.patch('/repos/:name', async (req, res) => {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error updating repo:', error);
log.error({ err: error }, 'Error updating repo');
res.status(400).json({ error: error.message || 'Failed to update repo' });
}
});
@ -91,7 +93,7 @@ router.delete('/repos/:name', async (req, res) => {
const config = await configService.removeRepo(req.params.name);
res.json(config);
} catch (error: any) {
console.error('Error removing repo:', error);
log.error({ err: error }, 'Error removing repo');
res.status(400).json({ error: error.message || 'Failed to remove repo' });
}
});
@ -116,7 +118,7 @@ router.get('/repos/:name/branches', async (req, res) => {
const branches = await configService.getRepoBranches(req.params.name);
res.json(branches);
} catch (error: any) {
console.error('Error getting branches:', error);
log.error({ err: error }, 'Error getting branches');
res.status(400).json({ error: error.message || 'Failed to get branches' });
}
});
@ -127,7 +129,7 @@ router.get('/agents', async (_req, res) => {
const config = await configService.getConfig();
res.json(config.agents);
} catch (error) {
console.error('Error listing agents:', error);
log.error({ err: error }, 'Error listing agents');
res.status(500).json({ error: 'Failed to list agents' });
}
});
@ -142,7 +144,7 @@ router.put('/agents', async (req, res) => {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error updating agents:', error);
log.error({ err: error }, 'Error updating agents');
res.status(500).json({ error: 'Failed to update agents' });
}
});
@ -157,7 +159,7 @@ router.put('/default-agent', async (req, res) => {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error setting default agent:', error);
log.error({ err: error }, 'Error setting default agent');
res.status(500).json({ error: 'Failed to set default agent' });
}
});

View file

@ -2,6 +2,8 @@ import { Router } from 'express';
import { z } from 'zod';
import type { ManagedListItem } from '@veritas-kanban/shared';
import type { ManagedListService } from '../services/managed-list-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('managed-list-routes');
/**
* Create a generic Express router for a ManagedListService instance
@ -20,7 +22,7 @@ export function createManagedListRouter<T extends ManagedListItem>(
const items = await service.list(includeHidden);
res.json(items);
} catch (err) {
console.error('Error listing items:', err);
log.error({ err: err }, 'Error listing items');
res.status(500).json({ error: 'Failed to list items' });
}
});
@ -34,7 +36,7 @@ export function createManagedListRouter<T extends ManagedListItem>(
}
res.json(item);
} catch (err) {
console.error('Error getting item:', err);
log.error({ err: err }, 'Error getting item');
res.status(500).json({ error: 'Failed to get item' });
}
});
@ -44,11 +46,11 @@ export function createManagedListRouter<T extends ManagedListItem>(
try {
// Validate with custom schema if provided
const data = createSchema ? createSchema.parse(req.body) : req.body;
const item = await service.create(data);
res.status(201).json(item);
} catch (err: any) {
console.error('Error creating item:', err);
log.error({ err: err }, 'Error creating item');
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'Validation error', details: err.errors });
}
@ -61,14 +63,14 @@ export function createManagedListRouter<T extends ManagedListItem>(
try {
// Validate with custom schema if provided
const data = updateSchema ? updateSchema.parse(req.body) : req.body;
const item = await service.update(req.params.id, data);
if (!item) {
return res.status(404).json({ error: 'Item not found' });
}
res.json(item);
} catch (err: any) {
console.error('Error updating item:', err);
log.error({ err: err }, 'Error updating item');
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'Validation error', details: err.errors });
}
@ -81,20 +83,20 @@ export function createManagedListRouter<T extends ManagedListItem>(
try {
const force = req.query.force === 'true';
const result = await service.delete(req.params.id, force);
if (!result.deleted) {
if (result.referenceCount !== undefined && result.referenceCount > 0) {
return res.status(400).json({
return res.status(400).json({
error: 'Cannot delete item with references',
referenceCount: result.referenceCount,
});
}
return res.status(400).json({ error: 'Cannot delete default item or item not found' });
}
res.status(204).send();
} catch (err) {
console.error('Error deleting item:', err);
log.error({ err: err }, 'Error deleting item');
res.status(500).json({ error: 'Failed to delete item' });
}
});
@ -105,7 +107,7 @@ export function createManagedListRouter<T extends ManagedListItem>(
const result = await service.canDelete(req.params.id);
res.json(result);
} catch (err) {
console.error('Error checking delete permission:', err);
log.error({ err: err }, 'Error checking delete permission');
res.status(500).json({ error: 'Failed to check delete permission' });
}
});
@ -116,12 +118,12 @@ export function createManagedListRouter<T extends ManagedListItem>(
const schema = z.object({
orderedIds: z.array(z.string()),
});
const { orderedIds } = schema.parse(req.body);
const items = await service.reorder(orderedIds);
res.json(items);
} catch (err: any) {
console.error('Error reordering items:', err);
log.error({ err: err }, 'Error reordering items');
if (err.name === 'ZodError') {
return res.status(400).json({ error: 'Validation error', details: err.errors });
}

View file

@ -3,6 +3,8 @@ import { z } from 'zod';
import { ProjectService } from '../services/project-service.js';
import { TaskService } from '../services/task-service.js';
import { createManagedListRouter } from './managed-list-routes.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('projects');
// Validation schemas
const createProjectSchema = z.object({
@ -23,15 +25,11 @@ const taskService = new TaskService();
const projectService = new ProjectService(taskService);
// Initialize service
projectService.init().catch(err => {
console.error('Failed to initialize ProjectService:', err);
projectService.init().catch((err) => {
log.error('Failed to initialize ProjectService:', err);
});
// Create router using the generic factory
const router = createManagedListRouter(
projectService,
createProjectSchema,
updateProjectSchema
);
const router = createManagedListRouter(projectService, createProjectSchema, updateProjectSchema);
export default router;

View file

@ -7,6 +7,8 @@ import { FeatureSettingsPatchSchema } from '../schemas/feature-settings-schema.j
import { strictRateLimit } from '../middleware/rate-limit.js';
import { auditLog } from '../services/audit-service.js';
import type { AuthenticatedRequest } from '../middleware/auth.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('settings');
const router: RouterType = Router();
const configService = new ConfigService();
@ -39,7 +41,7 @@ router.get('/features', async (_req, res) => {
const features = await configService.getFeatureSettings();
res.json(features);
} catch (error) {
console.error('Error getting feature settings:', error);
log.error({ err: error }, 'Error getting feature settings');
res.status(500).json({ error: 'Failed to get feature settings' });
}
});
@ -76,7 +78,7 @@ router.patch('/features', strictRateLimit, async (req, res) => {
res.json(updated);
} catch (error) {
console.error('Error updating feature settings:', error);
log.error({ err: error }, 'Error updating feature settings');
res.status(500).json({ error: 'Failed to update feature settings' });
}
});

View file

@ -3,6 +3,8 @@ import { z } from 'zod';
import { SprintService } from '../services/sprint-service.js';
import { TaskService } from '../services/task-service.js';
import { createManagedListRouter } from './managed-list-routes.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('sprints');
// Validation schemas
const createSprintSchema = z.object({
@ -21,15 +23,11 @@ const taskService = new TaskService();
const sprintService = new SprintService(taskService);
// Initialize service
sprintService.init().catch(err => {
console.error('Failed to initialize SprintService:', err);
sprintService.init().catch((err) => {
log.error('Failed to initialize SprintService:', err);
});
// Create router using the generic factory
const router = createManagedListRouter(
sprintService,
createSprintSchema,
updateSprintSchema
);
const router = createManagedListRouter(sprintService, createSprintSchema, updateSprintSchema);
export default router;

View file

@ -3,6 +3,8 @@ import { z } from 'zod';
import { TaskTypeService } from '../services/task-type-service.js';
import { TaskService } from '../services/task-service.js';
import { createManagedListRouter } from './managed-list-routes.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('task-types');
// Validation schemas
const createTaskTypeSchema = z.object({
@ -23,15 +25,11 @@ const taskService = new TaskService();
const taskTypeService = new TaskTypeService(taskService);
// Initialize service
taskTypeService.init().catch(err => {
console.error('Failed to initialize TaskTypeService:', err);
taskTypeService.init().catch((err) => {
log.error('Failed to initialize TaskTypeService:', err);
});
// Create router using the generic factory
const router = createManagedListRouter(
taskTypeService,
createTaskTypeSchema,
updateTaskTypeSchema
);
const router = createManagedListRouter(taskTypeService, createTaskTypeSchema, updateTaskTypeSchema);
export default router;

View file

@ -20,6 +20,8 @@ import {
type TelemetryBulkQuery,
type TelemetryExportQuery,
} from '../schemas/telemetry-schemas.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('telemetry');
const router: RouterType = Router();
@ -28,15 +30,15 @@ const router: RouterType = Router();
/**
* POST /api/telemetry/events
* Ingest telemetry events from external sources (Veritas, Clawdbot, etc.)
*
*
* Accepts: run.started, run.completed, run.error, run.tokens events
*
*
* Request body:
* - type: Event type (required)
* - taskId: Task ID this event relates to (required)
* - agent: Agent name/type (required)
* - ...type-specific fields
*
*
* Response: The created event with generated id and timestamp
*/
router.post(
@ -45,13 +47,13 @@ router.post(
asyncHandler(async (req: ValidatedRequest<unknown, unknown, TelemetryEventIngestion>, res) => {
const telemetry = getTelemetryService();
const eventInput = req.validated.body!;
// Emit the event (adds id and timestamp)
const event = await telemetry.emit(eventInput);
// Broadcast to WebSocket clients
broadcastTelemetryEvent(event as AnyTelemetryEvent);
// Check for failure events and send alerts (non-blocking)
const failureAlertService = getFailureAlertService();
if (failureAlertService.isFailureEvent(eventInput)) {
@ -66,15 +68,15 @@ router.post(
} catch {
// Task not found is fine, we'll use taskId
}
await failureAlertService.processEvent(eventInput, taskTitle);
} catch (err) {
// Graceful failure: log but don't crash
console.error('[Telemetry] Failure alert error:', err);
log.error({ err: err }, '[Telemetry] Failure alert error');
}
})();
}
res.status(201).json(event);
})
);
@ -91,9 +93,9 @@ router.get(
asyncHandler(async (req: ValidatedRequest<unknown, TelemetryEventsQuery>, res) => {
const telemetry = getTelemetryService();
const { type, since, until, taskId, project, limit } = req.validated.query!;
const options: TelemetryQueryOptions = {};
if (type && type.length > 0) {
options.type = type.length === 1 ? type[0] : type;
}
@ -102,7 +104,7 @@ router.get(
if (taskId) options.taskId = taskId;
if (project) options.project = project;
if (limit) options.limit = limit;
const events = await telemetry.getEvents(options);
res.json(events);
})
@ -126,7 +128,7 @@ router.get(
/**
* POST /api/telemetry/events/bulk
* Get events for multiple tasks in one request (batch query)
*
*
* Returns: { [taskId]: events[] }
*/
router.post(
@ -135,15 +137,15 @@ router.post(
asyncHandler(async (req: ValidatedRequest<unknown, unknown, TelemetryBulkQuery>, res) => {
const telemetry = getTelemetryService();
const { taskIds } = req.validated.body!;
const eventsMap = await telemetry.getBulkTaskEvents(taskIds);
// Convert Map to plain object for JSON response
const result: Record<string, AnyTelemetryEvent[]> = {};
for (const [taskId, events] of eventsMap) {
result[taskId] = events;
}
res.json(result);
})
);
@ -152,16 +154,19 @@ router.post(
* GET /api/telemetry/status
* Get telemetry service status and configuration
*/
router.get('/status', asyncHandler(async (_req, res) => {
const telemetry = getTelemetryService();
const config = telemetry.getConfig();
res.json({
enabled: config.enabled,
retention: config.retention,
traces: config.traces,
});
}));
router.get(
'/status',
asyncHandler(async (_req, res) => {
const telemetry = getTelemetryService();
const config = telemetry.getConfig();
res.json({
enabled: config.enabled,
retention: config.retention,
traces: config.traces,
});
})
);
/**
* GET /api/telemetry/count
@ -173,13 +178,9 @@ router.get(
asyncHandler(async (req: ValidatedRequest<unknown, TelemetryCountQuery>, res) => {
const telemetry = getTelemetryService();
const { type, since, until } = req.validated.query!;
const count = await telemetry.countEvents(
type.length === 1 ? type[0] : type,
since,
until
);
const count = await telemetry.countEvents(type.length === 1 ? type[0] : type, since, until);
res.json({ count });
})
);
@ -187,14 +188,14 @@ router.get(
/**
* GET /api/telemetry/export
* Export telemetry events as CSV or JSON file download
*
*
* Query params:
* - format: 'csv' | 'json' (default: 'json')
* - taskId: Filter by specific task
* - project: Filter by project name
* - from: Start date (ISO timestamp)
* - to: End date (ISO timestamp)
*
*
* Response: File download with appropriate Content-Disposition header
*/
router.get(
@ -203,23 +204,23 @@ router.get(
asyncHandler(async (req: ValidatedRequest<unknown, TelemetryExportQuery>, res) => {
const telemetry = getTelemetryService();
const { format, taskId, project, from, to } = req.validated.query!;
// Build query options
const options: TelemetryQueryOptions = {};
if (taskId) options.taskId = taskId;
if (project) options.project = project;
if (from) options.since = from;
if (to) options.until = to;
// Generate filename with scope and date info
const scopeParts: string[] = ['telemetry'];
if (taskId) scopeParts.push(`task-${taskId}`);
else if (project) scopeParts.push(`project-${project.replace(/[^a-zA-Z0-9-_]/g, '_')}`);
else scopeParts.push('full');
const dateStr = new Date().toISOString().slice(0, 10);
const filename = `${scopeParts.join('-')}-${dateStr}.${format}`;
if (format === 'csv') {
const csvData = await telemetry.exportAsCsv(options);
res.setHeader('Content-Type', 'text/csv');

View file

@ -1,6 +1,8 @@
import { Router, type Router as RouterType } from 'express';
import { z } from 'zod';
import { TemplateService } from '../services/template-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('templates');
const router: RouterType = Router();
const templateService = new TemplateService();
@ -44,13 +46,15 @@ const updateTemplateSchema = z.object({
name: z.string().min(1).optional(),
description: z.string().optional(),
category: z.string().optional(),
taskDefaults: z.object({
type: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
descriptionTemplate: z.string().optional(),
agent: z.enum(['claude-code', 'amp', 'copilot', 'gemini', 'veritas']).optional(),
}).optional(),
taskDefaults: z
.object({
type: z.string().optional(),
priority: z.enum(['low', 'medium', 'high']).optional(),
project: z.string().optional(),
descriptionTemplate: z.string().optional(),
agent: z.enum(['claude-code', 'amp', 'copilot', 'gemini', 'veritas']).optional(),
})
.optional(),
subtaskTemplates: z.array(subtaskTemplateSchema).optional(),
blueprint: z.array(blueprintTaskSchema).optional(),
});
@ -61,7 +65,7 @@ router.get('/', async (_req, res) => {
const templates = await templateService.getTemplates();
res.json(templates);
} catch (error) {
console.error('Error listing templates:', error);
log.error({ err: error }, 'Error listing templates');
res.status(500).json({ error: 'Failed to list templates' });
}
});
@ -75,7 +79,7 @@ router.get('/:id', async (req, res) => {
}
res.json(template);
} catch (error) {
console.error('Error getting template:', error);
log.error({ err: error }, 'Error getting template');
res.status(500).json({ error: 'Failed to get template' });
}
});
@ -90,7 +94,7 @@ router.post('/', async (req, res) => {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error creating template:', error);
log.error({ err: error }, 'Error creating template');
res.status(500).json({ error: 'Failed to create template' });
}
});
@ -108,7 +112,7 @@ router.patch('/:id', async (req, res) => {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation failed', details: error.errors });
}
console.error('Error updating template:', error);
log.error({ err: error }, 'Error updating template');
res.status(500).json({ error: 'Failed to update template' });
}
});
@ -122,7 +126,7 @@ router.delete('/:id', async (req, res) => {
}
res.status(204).send();
} catch (error) {
console.error('Error deleting template:', error);
log.error({ err: error }, 'Error deleting template');
res.status(500).json({ error: 'Failed to delete template' });
}
});

View file

@ -1,6 +1,8 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { createLogger } from '../lib/logger.js';
const log = createLogger('activity-service');
export type ActivityType =
| 'task_created'
@ -94,7 +96,7 @@ export class ActivityService {
activities = [activity, ...activities].slice(0, this.MAX_ACTIVITIES);
if (activities.length >= this.MAX_ACTIVITIES) {
console.warn(
log.warn(
`[Activity] Activity limit reached (${this.MAX_ACTIVITIES}), trimming oldest entries`
);
}

View file

@ -5,6 +5,8 @@ import mime from 'mime-types';
import type { Attachment, AttachmentLimits } from '@veritas-kanban/shared';
import { DEFAULT_ATTACHMENT_LIMITS, ALLOWED_MIME_TYPES } from '@veritas-kanban/shared';
import { validateMimeType, getAllowedTypesDescription } from './mime-validation.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('attachment-service');
// Default paths - resolve to project root (one level up from server/)
const DEFAULT_PROJECT_ROOT = path.resolve(process.cwd(), '..');
@ -67,7 +69,7 @@ export class AttachmentService {
!normalizedPath.startsWith(normalizedBase + path.sep) &&
normalizedPath !== normalizedBase
) {
console.error(`Path traversal attempt blocked: ${context}`, { resolvedPath, baseDir });
log.error({ err: { resolvedPath, baseDir } }, `Path traversal attempt blocked: ${context}`);
throw new Error('Invalid path: access denied');
}
}
@ -257,7 +259,7 @@ export class AttachmentService {
try {
await fs.unlink(filepath);
} catch (err) {
console.error(`Failed to delete attachment file: ${filepath}`, err);
log.error({ err: err }, `Failed to delete attachment file: ${filepath}`);
}
// Delete extracted text
@ -318,7 +320,7 @@ export class AttachmentService {
} catch (err) {
// Ignore if source doesn't exist
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(`Failed to archive attachments for task ${taskId}:`, err);
log.error({ err: err }, `Failed to archive attachments for task ${taskId}`);
}
}
}
@ -342,7 +344,7 @@ export class AttachmentService {
} catch (err) {
// Ignore if source doesn't exist
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
console.error(`Failed to restore attachments for task ${taskId}:`, err);
log.error({ err: err }, `Failed to restore attachments for task ${taskId}`);
}
}
}
@ -356,7 +358,7 @@ export class AttachmentService {
try {
await fs.rm(taskDir, { recursive: true, force: true });
} catch (err) {
console.error(`Failed to delete attachments directory for task ${taskId}:`, err);
log.error({ err: err }, `Failed to delete attachments directory for task ${taskId}`);
}
}

View file

@ -18,6 +18,8 @@ import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
import { getBreaker } from './circuit-registry.js';
import type { Task, AgentType, TaskAttempt, AttemptStatus } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('clawdbot-agent-service');
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
const LOGS_DIR = path.join(PROJECT_ROOT, '.veritas-kanban', 'logs');
@ -189,8 +191,8 @@ export class ClawdbotAgentService {
)
);
console.log(`[ClawdbotAgent] Wrote agent request for task ${taskId} to ${requestFile}`);
console.log(
log.info(`[ClawdbotAgent] Wrote agent request for task ${taskId} to ${requestFile}`);
log.info(
`[ClawdbotAgent] Veritas should pick this up on next heartbeat or you can trigger manually`
);
}
@ -204,7 +206,7 @@ export class ClawdbotAgentService {
): Promise<void> {
const pending = pendingAgents.get(taskId);
if (!pending) {
console.warn(`[ClawdbotAgent] Received completion for unknown task ${taskId}`);
log.warn(`[ClawdbotAgent] Received completion for unknown task ${taskId}`);
return;
}
@ -248,7 +250,7 @@ export class ClawdbotAgentService {
// Ignore if already deleted
}
console.log(`[ClawdbotAgent] Task ${taskId} completed with status: ${status}`);
log.info(`[ClawdbotAgent] Task ${taskId} completed with status: ${status}`);
}
/**

View file

@ -1,6 +1,8 @@
import { simpleGit } from 'simple-git';
import { TaskService } from './task-service.js';
import type { Task } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('diff-service');
export interface FileChange {
path: string;
@ -55,36 +57,41 @@ export class DiffService {
private getLanguageFromPath(filePath: string): string {
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const langMap: Record<string, string> = {
'ts': 'typescript',
'tsx': 'tsx',
'js': 'javascript',
'jsx': 'jsx',
'json': 'json',
'md': 'markdown',
'css': 'css',
'scss': 'scss',
'html': 'html',
'py': 'python',
'rs': 'rust',
'go': 'go',
'rb': 'ruby',
'java': 'java',
'sh': 'bash',
'yaml': 'yaml',
'yml': 'yaml',
'toml': 'toml',
'sql': 'sql',
ts: 'typescript',
tsx: 'tsx',
js: 'javascript',
jsx: 'jsx',
json: 'json',
md: 'markdown',
css: 'css',
scss: 'scss',
html: 'html',
py: 'python',
rs: 'rust',
go: 'go',
rb: 'ruby',
java: 'java',
sh: 'bash',
yaml: 'yaml',
yml: 'yaml',
toml: 'toml',
sql: 'sql',
};
return langMap[ext] || 'plaintext';
}
private parseStatusCode(code: string): FileChange['status'] {
switch (code[0]) {
case 'A': return 'added';
case 'M': return 'modified';
case 'D': return 'deleted';
case 'R': return 'renamed';
default: return 'modified';
case 'A':
return 'added';
case 'M':
return 'modified';
case 'D':
return 'deleted';
case 'R':
return 'renamed';
default:
return 'modified';
}
}
@ -99,21 +106,24 @@ export class DiffService {
// Get diff against base branch
const baseBranch = task.git.baseBranch || 'main';
// Get list of changed files with stats
const diffStat = await git.diffSummary([baseBranch]);
const files: FileChange[] = diffStat.files.map(file => {
const files: FileChange[] = diffStat.files.map((file) => {
const additions = 'insertions' in file ? file.insertions : 0;
const deletions = 'deletions' in file ? file.deletions : 0;
const isBinary = 'binary' in file && file.binary;
return {
path: file.file,
status: isBinary ? 'modified' as const : (
additions > 0 && deletions === 0 ? 'added' as const :
deletions > 0 && additions === 0 ? 'deleted' as const : 'modified' as const
),
status: isBinary
? ('modified' as const)
: additions > 0 && deletions === 0
? ('added' as const)
: deletions > 0 && additions === 0
? ('deleted' as const)
: ('modified' as const),
additions,
deletions,
};
@ -139,7 +149,7 @@ export class DiffService {
// Get unified diff for the file
const diffOutput = await git.diff([baseBranch, '--', filePath]);
// Get file stats
const diffStat = await git.diffSummary([baseBranch, '--', filePath]);
const fileStat = diffStat.files[0];
@ -151,7 +161,7 @@ export class DiffService {
let status: FileChange['status'] = 'modified';
let additions = 0;
let deletions = 0;
if (fileStat && 'insertions' in fileStat) {
additions = fileStat.insertions;
deletions = fileStat.deletions;
@ -172,7 +182,7 @@ export class DiffService {
private parseUnifiedDiff(diffOutput: string): DiffHunk[] {
const hunks: DiffHunk[] = [];
const lines = diffOutput.split('\n');
let currentHunk: DiffHunk | null = null;
let oldLineNum = 0;
let newLineNum = 0;
@ -180,15 +190,15 @@ export class DiffService {
for (const line of lines) {
// Match hunk header: @@ -start,count +start,count @@
const hunkMatch = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
if (hunkMatch) {
if (currentHunk) {
hunks.push(currentHunk);
}
oldLineNum = parseInt(hunkMatch[1], 10);
newLineNum = parseInt(hunkMatch[3], 10);
currentHunk = {
oldStart: oldLineNum,
oldLines: parseInt(hunkMatch[2] || '1', 10),
@ -202,11 +212,13 @@ export class DiffService {
if (!currentHunk) continue;
// Skip diff metadata lines
if (line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('---') ||
line.startsWith('+++') ||
line.startsWith('\\')) {
if (
line.startsWith('diff --git') ||
line.startsWith('index ') ||
line.startsWith('---') ||
line.startsWith('+++') ||
line.startsWith('\\')
) {
continue;
}
@ -249,7 +261,7 @@ export class DiffService {
diffs.push(diff);
} catch (e) {
// Skip files that can't be diffed (binary, etc.)
console.warn(`Could not get diff for ${file.path}:`, e);
log.warn({ data: e }, `Could not get diff for ${file.path}`);
}
}

View file

@ -1,9 +1,9 @@
/**
* Failure Alert Service
*
*
* Automatically notifies Teams when agent runs fail.
* Detects run.error and run.completed with success:false telemetry events.
*
*
* Features:
* - Configurable on/off (default off)
* - Deduplication: won't spam for same task retries within 5 min
@ -61,7 +61,7 @@ export class FailureAlertService {
isRecentlyAlerted(taskId: string): boolean {
const lastAlert = recentAlerts.get(taskId);
if (!lastAlert) return false;
const elapsed = Date.now() - lastAlert;
return elapsed < this.dedupWindowMs;
}
@ -71,7 +71,7 @@ export class FailureAlertService {
*/
private recordAlert(taskId: string): void {
recentAlerts.set(taskId, Date.now());
// Clean up old entries periodically
if (recentAlerts.size > 100) {
const cutoff = Date.now() - this.dedupWindowMs;
@ -122,14 +122,14 @@ export class FailureAlertService {
// Check if notifications are enabled
const features = await this.configService.getFeatureSettings();
const notifSettings = features.notifications;
if (!notifSettings.enabled || !notifSettings.onAgentFailure) {
return { sent: false, reason: 'disabled' };
}
// Check deduplication
if (this.isRecentlyAlerted(event.taskId)) {
console.log(`[FailureAlert] Skipping duplicate alert for task ${event.taskId}`);
log.info(`[FailureAlert] Skipping duplicate alert for task ${event.taskId}`);
return { sent: false, reason: 'deduplicated' };
}
@ -163,13 +163,13 @@ export class FailureAlertService {
});
// Log success
console.log(`[FailureAlert] Created notification ${notification.id} for task ${event.taskId}`);
log.info(`[FailureAlert] Created notification ${notification.id} for task ${event.taskId}`);
return { sent: true, notificationId: notification.id };
} catch (err) {
// Graceful failure: log but don't crash
const errorMsg = err instanceof Error ? err.message : String(err);
console.error(`[FailureAlert] Failed to send alert: ${errorMsg}`);
log.error(`[FailureAlert] Failed to send alert: ${errorMsg}`);
return { sent: false, reason: 'error', error: errorMsg };
}
}
@ -198,7 +198,7 @@ export class FailureAlertService {
try {
const features = await this.configService.getFeatureSettings();
const webhookUrl = features.notifications.webhookUrl;
if (!webhookUrl) {
// No webhook configured - notification will be available via polling
return false;
@ -213,14 +213,14 @@ export class FailureAlertService {
});
if (!response.ok) {
console.warn(`[FailureAlert] Webhook delivery failed: ${response.status}`);
log.warn(`[FailureAlert] Webhook delivery failed: ${response.status}`);
return false;
}
console.log('[FailureAlert] Webhook delivery successful');
log.info('[FailureAlert] Webhook delivery successful');
return true;
} catch (err) {
console.warn('[FailureAlert] Webhook error:', err instanceof Error ? err.message : err);
log.warn({ data: err instanceof Error ? err.message : err }, '[FailureAlert] Webhook error');
return false;
}
}
@ -264,3 +264,5 @@ export function getFailureAlertService(): FailureAlertService {
// Re-export ConfigService getter for completeness
export { getConfigService } from './config-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('failure-alert-service');

View file

@ -3,6 +3,8 @@ import { promisify } from 'util';
import { ConfigService } from './config-service.js';
import { TaskService } from './task-service.js';
import { getBreaker } from './circuit-registry.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('github-service');
const execAsync = promisify(exec);
const execFileAsync = promisify(execFile);
@ -165,7 +167,7 @@ export class GitHubService {
} catch (error: any) {
// Ignore if already pushed
if (!error.message?.includes('Everything up-to-date')) {
console.warn('Push warning:', error.message);
log.warn('Push warning:', error.message);
}
}

View file

@ -3,6 +3,8 @@ import { existsSync } from 'fs';
import { join } from 'path';
import { nanoid } from 'nanoid';
import type { ManagedListItem } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('managed-list-service');
export interface ManagedListServiceConfig<T extends ManagedListItem> {
filename: string;
@ -50,7 +52,7 @@ export class ManagedListService<T extends ManagedListItem> {
const content = await readFile(this.filePath, 'utf-8');
this.items = JSON.parse(content);
} catch (err) {
console.error('Error loading managed list:', err);
log.error({ err: err }, 'Error loading managed list');
this.items = [...this.defaults];
}
}

View file

@ -35,6 +35,8 @@ import type {
AgentRecommendation,
AgentComparisonResult,
} from './types.js';
import { createLogger } from '../../lib/logger.js';
const log = createLogger('dashboard-metrics');
/**
* Get all metrics in one call (for dashboard).
@ -156,7 +158,7 @@ export async function computeAllMetrics(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}
@ -300,7 +302,7 @@ export async function computeAllMetrics(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}
@ -424,7 +426,7 @@ export async function computeTrends(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}
@ -574,7 +576,7 @@ export async function computeAgentComparison(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}

View file

@ -12,6 +12,8 @@ import type {
DurationMetrics,
FailedRunDetails,
} from './types.js';
import { createLogger } from '../../lib/logger.js';
const log = createLogger('run-metrics');
/**
* Get run metrics (error rate, success rate) with per-agent breakdown
@ -221,7 +223,7 @@ export async function computeFailedRuns(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}

View file

@ -8,6 +8,8 @@ import path from 'path';
import readline from 'readline';
import { createGunzip } from 'zlib';
import type { AnyTelemetryEvent, TelemetryEventType, StreamEventHandler } from './types.js';
import { createLogger } from '../../lib/logger.js';
const log = createLogger('telemetry-reader');
/**
* Get list of event files within a date range (includes .ndjson and .ndjson.gz)
@ -90,7 +92,7 @@ export async function streamEvents<T>(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}

View file

@ -5,6 +5,8 @@ import type { TokenTelemetryEvent, AnyTelemetryEvent } from '@veritas-kanban/sha
import { getPeriodStart, percentile } from './helpers.js';
import { getEventFiles, streamEvents, createLineReader } from './telemetry-reader.js';
import type { MetricsPeriod, TokenMetrics, TokenAccumulator, BudgetMetrics } from './types.js';
import { createLogger } from '../../lib/logger.js';
const log = createLogger('token-metrics');
/**
* Get token metrics with per-agent breakdown
@ -155,7 +157,7 @@ export async function computeBudgetMetrics(
}
} catch (error: any) {
if (error.code !== 'ENOENT') {
console.error(`[Metrics] Error reading ${filePath}:`, error.message);
log.error(`[Metrics] Error reading ${filePath}:`, error.message);
}
}
}

View file

@ -1,5 +1,7 @@
import { TaskService } from './task-service.js';
import type { TaskStatus } from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('migration-service');
/**
* One-time data migrations that run on server startup.
@ -21,11 +23,11 @@ export class MigrationService {
/**
* Migrate tasks with status "review" to "blocked"
*
*
* Background: The "review" status was removed from the workflow.
* Any existing tasks with that status should be converted to "blocked"
* so they remain visible and actionable.
*
*
* This migration is idempotent - if no tasks have status "review",
* it does nothing.
*/
@ -34,8 +36,7 @@ export class MigrationService {
// Cast to string for comparison since "review" is no longer in TaskStatus type
// but may exist in legacy data
const isReviewStatus = (status: TaskStatus): boolean =>
(status as string) === 'review';
const isReviewStatus = (status: TaskStatus): boolean => (status as string) === 'review';
// Migrate active tasks
const activeTasks = await this.taskService.listTasks();
@ -58,7 +59,7 @@ export class MigrationService {
}
if (migratedCount > 0) {
console.log(`Migrated ${migratedCount} tasks from review → blocked`);
log.info(`Migrated ${migratedCount} tasks from review → blocked`);
}
}
@ -74,10 +75,10 @@ export class MigrationService {
// Temporarily restore
await this.taskService.restoreTask(taskId);
// Update status (restoreTask sets status to 'done', so we need to correct it)
await this.taskService.updateTask(taskId, { status: 'blocked' });
// Re-archive
await this.taskService.archiveTask(taskId);
}

View file

@ -2,6 +2,8 @@ import { resolve } from 'path';
import type { ProjectConfig } from '@veritas-kanban/shared';
import { ManagedListService } from './managed-list-service.js';
import { TaskService } from './task-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('project-service');
// Color palette for auto-seeded projects
const PROJECT_COLORS = [
@ -23,7 +25,7 @@ export class ProjectService extends ManagedListService<ProjectConfig> {
constructor(taskService: TaskService) {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
super({
filename: 'projects.json',
configDir,
@ -59,7 +61,7 @@ export class ProjectService extends ManagedListService<ProjectConfig> {
private async seedProjectsFromTasks(): Promise<void> {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
const projectsFile = resolve(configDir, 'projects.json');
// Only seed if the file is empty or has no items
const existingProjects = await this.list(true);
if (existingProjects.length > 0) {
@ -75,7 +77,7 @@ export class ProjectService extends ManagedListService<ProjectConfig> {
const allTasks = [...activeTasks, ...archivedTasks];
const projectStrings = new Set<string>();
allTasks.forEach(task => {
allTasks.forEach((task) => {
if (task.project) {
projectStrings.add(task.project);
}
@ -86,13 +88,13 @@ export class ProjectService extends ManagedListService<ProjectConfig> {
// (tasks store project as a plain string that must match the project ID)
const projectArray = Array.from(projectStrings).sort();
const now = new Date().toISOString();
for (let i = 0; i < projectArray.length; i++) {
const projectName = projectArray[i];
const color = PROJECT_COLORS[i % PROJECT_COLORS.length];
await this.seedItem({
id: projectName, // Must match existing task.project values
id: projectName, // Must match existing task.project values
label: projectName,
color,
order: i,
@ -101,6 +103,6 @@ export class ProjectService extends ManagedListService<ProjectConfig> {
} as ProjectConfig);
}
console.log(`✅ Seeded ${projectArray.length} projects from existing tasks`);
log.info(`✅ Seeded ${projectArray.length} projects from existing tasks`);
}
}

View file

@ -2,6 +2,8 @@ import { resolve } from 'path';
import type { SprintConfig } from '@veritas-kanban/shared';
import { ManagedListService } from './managed-list-service.js';
import { TaskService } from './task-service.js';
import { createLogger } from '../lib/logger.js';
const log = createLogger('sprint-service');
export class SprintService extends ManagedListService<SprintConfig> {
private taskService: TaskService;
@ -9,7 +11,7 @@ export class SprintService extends ManagedListService<SprintConfig> {
constructor(taskService: TaskService) {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
super({
filename: 'sprints.json',
configDir,
@ -45,7 +47,7 @@ export class SprintService extends ManagedListService<SprintConfig> {
private async seedSprintsFromTasks(): Promise<void> {
const configDir = resolve(process.cwd(), '..', '.veritas-kanban');
const sprintsFile = resolve(configDir, 'sprints.json');
// Only seed if the file is empty or has no items
const existingSprints = await this.list(true);
if (existingSprints.length > 0) {
@ -61,7 +63,7 @@ export class SprintService extends ManagedListService<SprintConfig> {
const allTasks = [...activeTasks, ...archivedTasks];
const sprintStrings = new Set<string>();
allTasks.forEach(task => {
allTasks.forEach((task) => {
if (task.sprint) {
sprintStrings.add(task.sprint);
}
@ -72,12 +74,12 @@ export class SprintService extends ManagedListService<SprintConfig> {
// (tasks store sprint as a plain string that must match the sprint ID)
const sprintArray = Array.from(sprintStrings).sort();
const now = new Date().toISOString();
for (let i = 0; i < sprintArray.length; i++) {
const sprintName = sprintArray[i];
await this.seedItem({
id: sprintName, // Must match existing task.sprint values
id: sprintName, // Must match existing task.sprint values
label: sprintName,
order: i,
created: now,
@ -85,6 +87,6 @@ export class SprintService extends ManagedListService<SprintConfig> {
} as SprintConfig);
}
console.log(`✅ Seeded ${sprintArray.length} sprints from existing tasks`);
log.info(`✅ Seeded ${sprintArray.length} sprints from existing tasks`);
}
}

View file

@ -1,6 +1,8 @@
import { readFile, writeFile, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { createLogger } from '../lib/logger.js';
const log = createLogger('status-history-service');
export type AgentStatusState = 'idle' | 'working' | 'thinking' | 'sub-agent' | 'error';
@ -129,7 +131,7 @@ export class StatusHistoryService {
this.lastEntry = entry;
console.log(
log.info(
`[StatusHistory] ${previousStatus}${newStatus}${taskId ? ` (task: ${taskId})` : ''}`
);

View file

@ -11,6 +11,8 @@ import type {
TelemetryQueryOptions,
AnyTelemetryEvent,
} from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('telemetry-service');
// Default paths - resolve to project root
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
@ -125,9 +127,9 @@ export class TelemetryService {
this.pendingWrites.push(fullEvent);
if (this.pendingWrites.length > this.MAX_QUEUE_SIZE) {
const dropped = this.pendingWrites.shift();
console.warn(
`[Telemetry] Queue size exceeded (${this.MAX_QUEUE_SIZE}), dropped event:`,
dropped?.type
log.warn(
{ droppedType: dropped?.type },
`[Telemetry] Queue size exceeded (${this.MAX_QUEUE_SIZE}), dropped event`
);
}
@ -140,7 +142,7 @@ export class TelemetryService {
}
})
.catch((err) => {
console.error('[Telemetry] Failed to write event:', err);
log.error({ err: err }, '[Telemetry] Failed to write event');
});
this.writeQueue = writePromise;
@ -407,7 +409,7 @@ export class TelemetryService {
try {
return JSON.parse(line) as AnyTelemetryEvent;
} catch {
console.error('[Telemetry] Failed to parse line:', line);
log.error({ err: line }, '[Telemetry] Failed to parse line');
return null;
}
})
@ -497,13 +499,13 @@ export class TelemetryService {
await this.compressFile(filepath);
compressed++;
} catch (err) {
console.error(`[Telemetry] Failed to compress ${filename}:`, err);
log.error({ err: err }, `[Telemetry] Failed to compress ${filename}`);
}
}
}
if (deleted > 0 || compressed > 0) {
console.log(
log.info(
`[Telemetry] Cleanup: deleted ${deleted} expired file(s), compressed ${compressed} file(s) ` +
`(retention=${this.config.retention}d, compress=${this.compressAfterDays}d)`
);

View file

@ -2,7 +2,13 @@ import { readdir, readFile, writeFile, unlink, mkdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import matter from 'gray-matter';
import type { TaskTemplate, CreateTemplateInput, UpdateTemplateInput } from '@veritas-kanban/shared';
import type {
TaskTemplate,
CreateTemplateInput,
UpdateTemplateInput,
} from '@veritas-kanban/shared';
import { createLogger } from '../lib/logger.js';
const log = createLogger('template-service');
export class TemplateService {
private templatesDir: string;
@ -66,20 +72,20 @@ export class TemplateService {
async getTemplates(): Promise<TaskTemplate[]> {
await this.ensureDir();
const files = await readdir(this.templatesDir);
const templates: TaskTemplate[] = [];
for (const file of files) {
if (!file.endsWith('.md')) continue;
try {
const content = await readFile(join(this.templatesDir, file), 'utf-8');
const { data } = matter(content);
const migrated = this.migrateTemplate(data);
templates.push(migrated);
} catch (err) {
console.error(`Error reading template ${file}:`, err);
log.error({ err: err }, `Error reading template ${file}`);
}
}
@ -88,7 +94,7 @@ export class TemplateService {
async getTemplate(id: string): Promise<TaskTemplate | null> {
const path = this.templatePath(id);
if (!existsSync(path)) {
return null;
}
@ -98,17 +104,17 @@ export class TemplateService {
const { data } = matter(content);
return this.migrateTemplate(data);
} catch (err) {
console.error(`Error reading template ${id}:`, err);
log.error({ err: err }, `Error reading template ${id}`);
return null;
}
}
async createTemplate(input: CreateTemplateInput): Promise<TaskTemplate> {
await this.ensureDir();
const id = `template_${this.slugify(input.name)}_${Date.now()}`;
const now = new Date().toISOString();
const template: TaskTemplate = {
id,
name: input.name,
@ -124,7 +130,7 @@ export class TemplateService {
const content = matter.stringify('', template);
await writeFile(this.templatePath(id), content, 'utf-8');
return template;
}
@ -149,13 +155,13 @@ export class TemplateService {
const content = matter.stringify('', updated);
await writeFile(this.templatePath(id), content, 'utf-8');
return updated;
}
async deleteTemplate(id: string): Promise<boolean> {
const path = this.templatePath(id);
if (!existsSync(path)) {
return false;
}

View file

@ -3,6 +3,8 @@ import path from 'path';
import { extractText as unpdfExtract } from 'unpdf';
import mammoth from 'mammoth';
import ExcelJS from 'exceljs';
import { createLogger } from '../lib/logger.js';
const log = createLogger('text-extraction-service');
export interface TextExtractionResult {
text: string | null;
@ -59,8 +61,12 @@ export class TextExtractionService {
}
// XML/YAML
if (mimeType === 'application/xml' || mimeType === 'text/xml' ||
mimeType === 'application/yaml' || mimeType === 'text/yaml') {
if (
mimeType === 'application/xml' ||
mimeType === 'text/xml' ||
mimeType === 'application/yaml' ||
mimeType === 'text/yaml'
) {
return await this.extractPlainText(filepath);
}
@ -72,7 +78,7 @@ export class TextExtractionService {
// Unknown type
return null;
} catch (error) {
console.error(`Text extraction failed for ${filepath}:`, error);
log.error({ err: error }, `Text extraction failed for ${filepath}`);
return null;
}
}
@ -94,7 +100,7 @@ export class TextExtractionService {
const { text } = await unpdfExtract(buffer, { mergePages: true });
return text || null;
} catch (error) {
console.error('PDF extraction error:', error);
log.error({ err: error }, 'PDF extraction error');
return null;
}
}
@ -108,7 +114,7 @@ export class TextExtractionService {
const result = await mammoth.extractRawText({ buffer });
return result.value || null;
} catch (error) {
console.error('DOCX extraction error:', error);
log.error({ err: error }, 'DOCX extraction error');
return null;
}
}
@ -120,23 +126,23 @@ export class TextExtractionService {
private async extractXLSX(filepath: string): Promise<string | null> {
try {
const workbook = new ExcelJS.Workbook();
// Read the workbook directly from file
await workbook.xlsx.readFile(filepath);
// Extract all sheets
const sheets: string[] = [];
workbook.eachSheet((worksheet, sheetId) => {
const rows: string[] = [];
worksheet.eachRow((row, rowNumber) => {
const values: string[] = [];
row.eachCell({ includeEmpty: true }, (cell, colNumber) => {
// Get cell value as string
const value = cell.value;
// Handle different value types
if (value === null || value === undefined) {
values.push('');
@ -150,18 +156,18 @@ export class TextExtractionService {
values.push(value.toString());
}
});
rows.push(values.join(','));
});
if (rows.length > 0) {
sheets.push(`=== Sheet: ${worksheet.name} ===\n${rows.join('\n')}`);
}
});
return sheets.length > 0 ? sheets.join('\n\n') : null;
} catch (error) {
console.error('XLSX extraction error:', error);
log.error({ err: error }, 'XLSX extraction error');
return null;
}
}
@ -172,7 +178,7 @@ export class TextExtractionService {
private async extractHTML(filepath: string): Promise<string | null> {
try {
const html = await fs.readFile(filepath, 'utf-8');
// Simple tag stripping (for more complex HTML, consider using a library like cheerio)
const text = html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
@ -180,10 +186,10 @@ export class TextExtractionService {
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text || null;
} catch (error) {
console.error('HTML extraction error:', error);
log.error({ err: error }, 'HTML extraction error');
return null;
}
}
@ -197,7 +203,7 @@ export class TextExtractionService {
const json = JSON.parse(content);
return JSON.stringify(json, null, 2);
} catch (error) {
console.error('JSON extraction error:', error);
log.error({ err: error }, 'JSON extraction error');
return null;
}
}

View file

@ -6,6 +6,8 @@ import { TaskService } from './task-service.js';
import type { Task } from '@veritas-kanban/shared';
import { spawn } from 'child_process';
import { promisify } from 'util';
import { createLogger } from '../lib/logger.js';
const log = createLogger('worktree-service');
// Default paths
const PROJECT_ROOT = path.resolve(process.cwd(), '..');
@ -151,7 +153,7 @@ export class WorktreeService {
await this.execGitWithTimeout(repoPath, ['fetch']);
} catch (e: any) {
// Ignore fetch errors (might be offline)
console.warn('Could not fetch from remote:', e.message);
log.warn('Could not fetch from remote:', e.message);
}
// Check if branch already exists
@ -215,7 +217,7 @@ export class WorktreeService {
const [behind, ahead] = log.trim().split('\t').map(Number);
aheadBehind = { ahead: ahead || 0, behind: behind || 0 };
} catch (e: any) {
console.warn('Could not get ahead/behind info:', e.message);
log.warn('Could not get ahead/behind info:', e.message);
}
return {