fix(projects): deterministic project identity + safe per-project persistence
Derive project.id from project.path (path_<base64url(path)>), shared helper on both server (lib/projects/project-id.js) and client (lib/projectId.ts). Replace random UUIDs so icons, notes, todos, actions, setup-worktree, plans and scheduledTasks share one id across restarts and reinstalls. Fix read-then-overwrite clobber in project-config.js: scheduled-task writes now merge with the existing project json instead of replacing it, preserving client-written fields that live in the same file. On settings load migrate legacy UUID ids to canonical path ids, moving config json, storage dir contents and icon files, and remap activeProjectId. Scan for orphan non-path_* project configs and merge them into the canonical project when a \$ROOT_PROJECT_PATH/<file> reference resolves on disk, logging any that can't be matched. Client openchamberConfig.writeOpenChamberConfig re-asserts server-owned keys (version, scheduledTasks) on write to defeat the symmetric race. Stores and persistence derive ids from path consistently.
This commit is contained in:
@@ -360,50 +360,57 @@ export const createProjectConfigRuntime = (deps) => {
|
||||
return path.join(projectsDirPath, `${safeProjectID}.json`);
|
||||
};
|
||||
|
||||
const readProjectConfigFromDisk = async (projectID) => {
|
||||
const readRawProjectConfigFromDisk = async (projectID) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
|
||||
try {
|
||||
const raw = await fsPromises.readFile(filePath, 'utf8');
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return createEmptyProjectConfig();
|
||||
}
|
||||
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
|
||||
const now = Date.now();
|
||||
const scheduledTasks = [];
|
||||
for (const task of tasksRaw) {
|
||||
try {
|
||||
const normalized = normalizeTaskForStorage(task, {
|
||||
now,
|
||||
createId: taskIDFactory,
|
||||
existingTask: null,
|
||||
allowCreate: true,
|
||||
});
|
||||
scheduledTasks.push(normalized);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks,
|
||||
};
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && error.code === 'ENOENT') {
|
||||
return createEmptyProjectConfig();
|
||||
return {};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const readProjectConfigFromDisk = async (projectID) => {
|
||||
const parsed = await readRawProjectConfigFromDisk(projectID);
|
||||
const tasksRaw = Array.isArray(parsed.scheduledTasks) ? parsed.scheduledTasks : [];
|
||||
const now = Date.now();
|
||||
const scheduledTasks = [];
|
||||
for (const task of tasksRaw) {
|
||||
try {
|
||||
const normalized = normalizeTaskForStorage(task, {
|
||||
now,
|
||||
createId: taskIDFactory,
|
||||
existingTask: null,
|
||||
allowCreate: true,
|
||||
});
|
||||
scheduledTasks.push(normalized);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks,
|
||||
};
|
||||
};
|
||||
|
||||
const writeProjectConfigToDisk = async (projectID, config) => {
|
||||
const filePath = resolveProjectConfigPath(projectID);
|
||||
const parentDirectory = path.dirname(filePath);
|
||||
const temporaryPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
const existing = await readRawProjectConfigFromDisk(projectID);
|
||||
const merged = {
|
||||
...existing,
|
||||
version: PROJECT_CONFIG_VERSION,
|
||||
scheduledTasks: Array.isArray(config?.scheduledTasks) ? config.scheduledTasks : [],
|
||||
};
|
||||
|
||||
await fsPromises.mkdir(parentDirectory, { recursive: true });
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
await fsPromises.writeFile(temporaryPath, JSON.stringify(merged, null, 2), 'utf8');
|
||||
await fsPromises.rename(temporaryPath, filePath);
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user