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
* 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 {
res.json(getDroidDashboardDiagnostics());
res.json(await getDroidDashboardDiagnostics());
} catch (error) {
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
* 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 {
res.json(getDroidRawSettings());
res.json(await getDroidRawSettings());
} catch (error) {
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
* 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 {
const { rawText, expectedMtime } = req.body ?? {};
@@ -54,7 +54,7 @@ router.put('/settings/raw', (req: Request, res: Response): void => {
return;
}
res.json(saveDroidRawSettings({ rawText, expectedMtime }));
res.json(await saveDroidRawSettings({ rawText, expectedMtime }));
} catch (error) {
if (error instanceof DroidRawSettingsValidationError) {
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';
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);
}
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,
label: string,
displayPath: string
): JsonFileProbe {
if (!fs.existsSync(filePath)) {
): Promise<JsonFileProbe> {
const stat = await statPath(filePath);
if (!stat) {
return {
diagnostics: {
label,
@@ -79,7 +91,6 @@ export function probeJsonObjectFile(
};
}
const stat = fs.lstatSync(filePath);
const diagnostics: JsonFileDiagnostics = {
label,
path: displayPath,
@@ -104,7 +115,7 @@ export function probeJsonObjectFile(
}
try {
const rawText = fs.readFileSync(filePath, 'utf8');
const rawText = await fs.readFile(filePath, 'utf8');
try {
const parsed = JSON.parse(rawText);
if (!isObject(parsed)) {
@@ -140,9 +151,9 @@ export function parseJsonObjectText(
return parsed;
}
export function writeJsonObjectFileAtomic(
export async function writeJsonObjectFileAtomic(
input: WriteJsonObjectFileInput
): WriteJsonObjectFileResult {
): Promise<WriteJsonObjectFileResult> {
const fileLabel = input.fileLabel || path.basename(input.filePath);
const parsed = parseJsonObjectText(input.rawText, fileLabel);
const targetPath = input.filePath;
@@ -151,10 +162,11 @@ export function writeJsonObjectFileAtomic(
const dirMode = input.dirMode ?? 0o700;
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 stat = fs.lstatSync(targetPath);
const targetStat = await statPath(targetPath);
if (targetStat) {
const stat = targetStat;
if (stat.isSymbolicLink()) {
throw new Error(`Refusing to write: ${fileLabel} is a symlink.`);
}
@@ -172,8 +184,9 @@ export function writeJsonObjectFileAtomic(
let wroteTemp = false;
try {
if (fs.existsSync(tempPath)) {
const tempStat = fs.lstatSync(tempPath);
const existingTempStat = await statPath(tempPath);
if (existingTempStat) {
const tempStat = existingTempStat;
if (tempStat.isSymbolicLink()) {
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;
const tempStat = fs.lstatSync(tempPath);
const tempStat = await fs.lstat(tempPath);
if (tempStat.isSymbolicLink()) {
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.`);
}
fs.renameSync(tempPath, targetPath);
await fs.rename(tempPath, targetPath);
wroteTemp = false;
try {
fs.chmodSync(targetPath, fileMode);
await fs.chmod(targetPath, fileMode);
} catch {
// Best-effort permission hardening.
}
const stat = fs.statSync(targetPath);
const stat = await fs.stat(targetPath);
return { mtime: stat.mtimeMs };
} finally {
if (wroteTemp && fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
if (wroteTemp) {
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 binaryPath = detectDroidCli();
const source = process.env.CCS_DROID_PATH ? 'CCS_DROID_PATH' : binaryPath ? 'PATH' : 'missing';
const settingsProbe = probeJsonObjectFile(
const settingsProbe = await probeJsonObjectFile(
paths.settingsPath,
'BYOK settings',
paths.settingsDisplayPath
);
const legacyConfigProbe = probeJsonObjectFile(
const legacyConfigProbe = await probeJsonObjectFile(
paths.legacyConfigPath,
'Legacy config',
paths.legacyConfigDisplayPath
@@ -222,9 +222,9 @@ export function getDroidDashboardDiagnostics(): DroidDashboardDiagnostics {
};
}
export function getDroidRawSettings(): DroidRawSettingsResponse {
export async function getDroidRawSettings(): Promise<DroidRawSettingsResponse> {
const paths = resolveDroidConfigPaths();
const settingsProbe = probeJsonObjectFile(
const settingsProbe = await probeJsonObjectFile(
paths.settingsPath,
'BYOK settings',
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();
if (typeof input.rawText !== 'string') {
throw new JsonFileValidationError('rawText must be a string.');
}
const saved = writeJsonObjectFileAtomic({
const saved = await writeJsonObjectFileAtomic({
filePath: paths.settingsPath,
rawText: input.rawText,
expectedMtime: input.expectedMtime,