refactor(dashboard): switch droid settings I/O to async fs

- replace sync fs calls in compatible CLI JSON helper with fs.promises

- make droid diagnostics/raw-settings service + routes async

- update unit tests for async save/read paths
This commit is contained in:
Tam Nhu Tran
2026-02-25 23:42:17 +07:00
parent e9eab712b3
commit 20e48b3dc0
4 changed files with 65 additions and 44 deletions
+6 -6
View File
@@ -14,9 +14,9 @@ const router = Router();
* GET /api/droid/diagnostics * GET /api/droid/diagnostics
* Dashboard-ready Droid installation + BYOK configuration diagnostics. * Dashboard-ready Droid installation + BYOK configuration diagnostics.
*/ */
router.get('/diagnostics', (_req: Request, res: Response): void => { router.get('/diagnostics', async (_req: Request, res: Response): Promise<void> => {
try { try {
res.json(getDroidDashboardDiagnostics()); res.json(await getDroidDashboardDiagnostics());
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
@@ -26,9 +26,9 @@ router.get('/diagnostics', (_req: Request, res: Response): void => {
* GET /api/droid/settings/raw * GET /api/droid/settings/raw
* Raw ~/.factory/settings.json payload for editor. * Raw ~/.factory/settings.json payload for editor.
*/ */
router.get('/settings/raw', (_req: Request, res: Response): void => { router.get('/settings/raw', async (_req: Request, res: Response): Promise<void> => {
try { try {
res.json(getDroidRawSettings()); res.json(await getDroidRawSettings());
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });
} }
@@ -38,7 +38,7 @@ router.get('/settings/raw', (_req: Request, res: Response): void => {
* PUT /api/droid/settings/raw * PUT /api/droid/settings/raw
* Save raw ~/.factory/settings.json payload from dashboard editor. * Save raw ~/.factory/settings.json payload from dashboard editor.
*/ */
router.put('/settings/raw', (req: Request, res: Response): void => { router.put('/settings/raw', async (req: Request, res: Response): Promise<void> => {
try { try {
const { rawText, expectedMtime } = req.body ?? {}; const { rawText, expectedMtime } = req.body ?? {};
@@ -54,7 +54,7 @@ router.put('/settings/raw', (req: Request, res: Response): void => {
return; return;
} }
res.json(saveDroidRawSettings({ rawText, expectedMtime })); res.json(await saveDroidRawSettings({ rawText, expectedMtime }));
} catch (error) { } catch (error) {
if (error instanceof DroidRawSettingsValidationError) { if (error instanceof DroidRawSettingsValidationError) {
res.status(400).json({ error: error.message }); res.status(400).json({ error: error.message });
@@ -1,4 +1,4 @@
import * as fs from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
export interface JsonFileDiagnostics { export interface JsonFileDiagnostics {
@@ -55,12 +55,24 @@ function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value); return typeof value === 'object' && value !== null && !Array.isArray(value);
} }
export function probeJsonObjectFile( async function statPath(filePath: string): Promise<import('fs').Stats | null> {
try {
return await fs.lstat(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
throw error;
}
}
export async function probeJsonObjectFile(
filePath: string, filePath: string,
label: string, label: string,
displayPath: string displayPath: string
): JsonFileProbe { ): Promise<JsonFileProbe> {
if (!fs.existsSync(filePath)) { const stat = await statPath(filePath);
if (!stat) {
return { return {
diagnostics: { diagnostics: {
label, label,
@@ -79,7 +91,6 @@ export function probeJsonObjectFile(
}; };
} }
const stat = fs.lstatSync(filePath);
const diagnostics: JsonFileDiagnostics = { const diagnostics: JsonFileDiagnostics = {
label, label,
path: displayPath, path: displayPath,
@@ -104,7 +115,7 @@ export function probeJsonObjectFile(
} }
try { try {
const rawText = fs.readFileSync(filePath, 'utf8'); const rawText = await fs.readFile(filePath, 'utf8');
try { try {
const parsed = JSON.parse(rawText); const parsed = JSON.parse(rawText);
if (!isObject(parsed)) { if (!isObject(parsed)) {
@@ -140,9 +151,9 @@ export function parseJsonObjectText(
return parsed; return parsed;
} }
export function writeJsonObjectFileAtomic( export async function writeJsonObjectFileAtomic(
input: WriteJsonObjectFileInput input: WriteJsonObjectFileInput
): WriteJsonObjectFileResult { ): Promise<WriteJsonObjectFileResult> {
const fileLabel = input.fileLabel || path.basename(input.filePath); const fileLabel = input.fileLabel || path.basename(input.filePath);
const parsed = parseJsonObjectText(input.rawText, fileLabel); const parsed = parseJsonObjectText(input.rawText, fileLabel);
const targetPath = input.filePath; const targetPath = input.filePath;
@@ -151,10 +162,11 @@ export function writeJsonObjectFileAtomic(
const dirMode = input.dirMode ?? 0o700; const dirMode = input.dirMode ?? 0o700;
const fileMode = input.fileMode ?? 0o600; const fileMode = input.fileMode ?? 0o600;
fs.mkdirSync(targetDir, { recursive: true, mode: dirMode }); await fs.mkdir(targetDir, { recursive: true, mode: dirMode });
if (fs.existsSync(targetPath)) { const targetStat = await statPath(targetPath);
const stat = fs.lstatSync(targetPath); if (targetStat) {
const stat = targetStat;
if (stat.isSymbolicLink()) { if (stat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`); throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
} }
@@ -172,8 +184,9 @@ export function writeJsonObjectFileAtomic(
let wroteTemp = false; let wroteTemp = false;
try { try {
if (fs.existsSync(tempPath)) { const existingTempStat = await statPath(tempPath);
const tempStat = fs.lstatSync(tempPath); if (existingTempStat) {
const tempStat = existingTempStat;
if (tempStat.isSymbolicLink()) { if (tempStat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
} }
@@ -182,10 +195,10 @@ export function writeJsonObjectFileAtomic(
} }
} }
fs.writeFileSync(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode }); await fs.writeFile(tempPath, JSON.stringify(parsed, null, 2) + '\n', { mode: fileMode });
wroteTemp = true; wroteTemp = true;
const tempStat = fs.lstatSync(tempPath); const tempStat = await fs.lstat(tempPath);
if (tempStat.isSymbolicLink()) { if (tempStat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`); throw new Error(`Refusing to write: ${fileLabel}.tmp is a symlink.`);
} }
@@ -193,20 +206,26 @@ export function writeJsonObjectFileAtomic(
throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`); throw new Error(`Refusing to write: ${fileLabel}.tmp is not a regular file.`);
} }
fs.renameSync(tempPath, targetPath); await fs.rename(tempPath, targetPath);
wroteTemp = false; wroteTemp = false;
try { try {
fs.chmodSync(targetPath, fileMode); await fs.chmod(targetPath, fileMode);
} catch { } catch {
// Best-effort permission hardening. // Best-effort permission hardening.
} }
const stat = fs.statSync(targetPath); const stat = await fs.stat(targetPath);
return { mtime: stat.mtimeMs }; return { mtime: stat.mtimeMs };
} finally { } finally {
if (wroteTemp && fs.existsSync(tempPath)) { if (wroteTemp) {
fs.unlinkSync(tempPath); try {
await fs.unlink(tempPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error;
}
}
} }
} }
} }
@@ -157,18 +157,18 @@ export function summarizeDroidCustomModels(customModelsValue: unknown): DroidByo
}; };
} }
export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics { export async function getDroidDashboardDiagnostics(): Promise<DroidDashboardDiagnostics> {
const paths = resolveDroidConfigPaths(); const paths = resolveDroidConfigPaths();
const binaryPath = detectDroidCli(); const binaryPath = detectDroidCli();
const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing'; const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing';
const settingsProbe = probeJsonObjectFile( const settingsProbe = await probeJsonObjectFile(
paths.settingsPath, paths.settingsPath,
'BYOK settings', 'BYOK settings',
paths.settingsDisplayPath paths.settingsDisplayPath
); );
const legacyConfigProbe = probeJsonObjectFile( const legacyConfigProbe = await probeJsonObjectFile(
paths.legacyConfigPath, paths.legacyConfigPath,
'Legacy config', 'Legacy config',
paths.legacyConfigDisplayPath paths.legacyConfigDisplayPath
@@ -222,9 +222,9 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics {
}; };
} }
export function getDroidRawSettings(): DroidRawSettingsResponse { export async function getDroidRawSettings(): Promise<DroidRawSettingsResponse> {
const paths = resolveDroidConfigPaths(); const paths = resolveDroidConfigPaths();
const settingsProbe = probeJsonObjectFile( const settingsProbe = await probeJsonObjectFile(
paths.settingsPath, paths.settingsPath,
'BYOK settings', 'BYOK settings',
paths.settingsDisplayPath paths.settingsDisplayPath
@@ -241,13 +241,15 @@ export function getDroidRawSettings(): DroidRawSettingsResponse {
}; };
} }
export function saveDroidRawSettings(input: SaveDroidRawSettingsInput): SaveDroidRawSettingsResult { export async function saveDroidRawSettings(
input: SaveDroidRawSettingsInput
): Promise<SaveDroidRawSettingsResult> {
const paths = resolveDroidConfigPaths(); const paths = resolveDroidConfigPaths();
if (typeof input.rawText !== 'string') { if (typeof input.rawText !== 'string') {
throw new JsonFileValidationError('rawText must be a string.'); throw new JsonFileValidationError('rawText must be a string.');
} }
const saved = writeJsonObjectFileAtomic({ const saved = await writeJsonObjectFileAtomic({
filePath: paths.settingsPath, filePath: paths.settingsPath,
rawText: input.rawText, rawText: input.rawText,
expectedMtime: input.expectedMtime, expectedMtime: input.expectedMtime,
@@ -88,8 +88,8 @@ describe('droid-dashboard-service', () => {
expect(summary.customModels[0].apiKeyPreview).toBe('***1234'); expect(summary.customModels[0].apiKeyPreview).toBe('***1234');
}); });
it('returns raw settings payload for missing settings file', () => { it('returns raw settings payload for missing settings file', async () => {
const raw = getDroidRawSettings(); const raw = await getDroidRawSettings();
expect(raw.exists).toBe(false); expect(raw.exists).toBe(false);
expect(raw.path).toBe('~/.factory/settings.json'); expect(raw.path).toBe('~/.factory/settings.json');
@@ -97,12 +97,12 @@ describe('droid-dashboard-service', () => {
expect(raw.settings).toBeNull(); expect(raw.settings).toBeNull();
}); });
it('returns parseError when settings.json is invalid JSON', () => { it('returns parseError when settings.json is invalid JSON', async () => {
const settingsDir = path.join(testRoot, '.factory'); const settingsDir = path.join(testRoot, '.factory');
fs.mkdirSync(settingsDir, { recursive: true }); fs.mkdirSync(settingsDir, { recursive: true });
fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json'); fs.writeFileSync(path.join(settingsDir, 'settings.json'), '{ invalid-json');
const raw = getDroidRawSettings(); const raw = await getDroidRawSettings();
expect(raw.exists).toBe(true); expect(raw.exists).toBe(true);
expect(raw.parseError).toBeString(); expect(raw.parseError).toBeString();
@@ -110,8 +110,8 @@ describe('droid-dashboard-service', () => {
expect(raw.rawText).toContain('invalid-json'); expect(raw.rawText).toContain('invalid-json');
}); });
it('saves valid raw settings content', () => { it('saves valid raw settings content', async () => {
const result = saveDroidRawSettings({ const result = await saveDroidRawSettings({
rawText: JSON.stringify({ rawText: JSON.stringify({
model: 'custom:test-model', model: 'custom:test-model',
customModels: [], customModels: [],
@@ -126,23 +126,23 @@ describe('droid-dashboard-service', () => {
expect(written.model).toBe('custom:test-model'); expect(written.model).toBe('custom:test-model');
}); });
it('rejects invalid JSON while saving raw settings', () => { it('rejects invalid JSON while saving raw settings', async () => {
expect(() => saveDroidRawSettings({ rawText: '{ invalid-json' })).toThrow( await expect(saveDroidRawSettings({ rawText: '{ invalid-json' })).rejects.toThrow(
DroidRawSettingsValidationError DroidRawSettingsValidationError
); );
}); });
it('rejects stale writes with conflict error', () => { it('rejects stale writes with conflict error', async () => {
const settingsDir = path.join(testRoot, '.factory'); const settingsDir = path.join(testRoot, '.factory');
fs.mkdirSync(settingsDir, { recursive: true }); fs.mkdirSync(settingsDir, { recursive: true });
const settingsPath = path.join(settingsDir, 'settings.json'); const settingsPath = path.join(settingsDir, 'settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] })); fs.writeFileSync(settingsPath, JSON.stringify({ customModels: [] }));
expect(() => await expect(
saveDroidRawSettings({ saveDroidRawSettings({
rawText: JSON.stringify({ model: 'custom:next', customModels: [] }), rawText: JSON.stringify({ model: 'custom:next', customModels: [] }),
expectedMtime: 1, expectedMtime: 1,
}) })
).toThrow(DroidRawSettingsConflictError); ).rejects.toThrow(DroidRawSettingsConflictError);
}); });
}); });