Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/components/digital-twin/NextActionBanner.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/digital-twin/tabs/DocumentsTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
5 changes: 2 additions & 3 deletions client/src/components/digital-twin/tabs/EnrichTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,17 +115,16 @@ export default function EnrichTab({ onRefresh }) {

setSavingWritingStyle(true);
try {
await api.createSoulDocument({
await api.createDigitalTwinDocument({
filename: 'WRITING_STYLE.md',
title: 'Writing Style',
category: 'core',
content: writingAnalysis.suggestedContent
}).catch(async () => {
// Document might exist, try to update by fetching ID
const docs = await api.getDigitalTwinDocuments();
const existing = docs.find(d => d.filename === 'WRITING_STYLE.md');
if (existing) {
return api.updateSoulDocument(existing.id, {
return api.updateDigitalTwinDocument(existing.id, {
content: writingAnalysis.suggestedContent
});
}
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/digital-twin/tabs/ExportTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/digital-twin/tabs/OverviewTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/digital-twin/tabs/TestTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
]);

Expand Down
4 changes: 2 additions & 2 deletions client/src/pages/DigitalTwin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
21 changes: 0 additions & 21 deletions client/src/services/apiDigitalTwin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
75 changes: 75 additions & 0 deletions server/lib/execGit.js
Original file line number Diff line number Diff line change
@@ -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
});
Comment on lines +8 to +26
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSDoc says this “prevents shell injection”, but on Windows this runs spawn with shell: true. Now that this shared helper is used by more services (e.g. worktrees/conflict checks), please either avoid shell: true (if feasible) or explicitly document/guard that all interpolated args (branch names, paths, etc.) are trusted/sanitized for Windows shell execution.

Copilot uses AI. Check for mistakes.

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);
});
});
}
18 changes: 18 additions & 0 deletions server/lib/validation.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}
Comment on lines +552 to +558
Copy link

Copilot AI Apr 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

parsePagination is newly introduced but server/lib/validation.test.js has extensive coverage for other helpers in this module; adding targeted tests for defaulting, clamping to maxLimit, negative/NaN inputs, and string/array query param shapes would help prevent regressions across all routes that now depend on it.

Copilot uses AI. Check for mistakes.

// =============================================================================
// TASK METADATA SANITIZATION
// =============================================================================
Expand Down
Loading