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:
Bohdan Triapitsyn
2026-04-18 13:46:38 +03:00
parent 5710198864
commit 4901cf60b8
9 changed files with 753 additions and 147 deletions
+3 -1
View File
@@ -1,3 +1,5 @@
import { createProjectIdFromPath } from '../projects/project-id.js';
export const registerOpenCodeRoutes = (app, dependencies) => {
const {
crypto,
@@ -177,7 +179,7 @@ export const registerOpenCodeRoutes = (app, dependencies) => {
: [
...existingProjects,
{
id: crypto.randomUUID(),
id: createProjectIdFromPath(resolvedPath),
path: resolvedPath,
addedAt: Date.now(),
lastOpenedAt: Date.now(),
@@ -1,3 +1,5 @@
import { createProjectIdFromPath } from '../projects/project-id.js';
const DEFAULT_NOTIFICATION_TEMPLATES = {
completion: { title: '{agent_name} is ready', message: '{model_name} completed the task' },
error: { title: 'Tool error', message: '{last_message}' },
@@ -46,6 +48,367 @@ export const createSettingsRuntime = (deps) => {
let persistSettingsLock = Promise.resolve();
const PROJECTS_ROOT_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'projects');
const PROJECT_ICONS_DIR = path.join(path.dirname(SETTINGS_FILE_PATH), 'project-icons');
const sha1Hex = (value) => crypto.createHash('sha1').update(value).digest('hex');
const projectIconBaseName = (projectId) => `project-${sha1Hex(projectId)}`;
const PROJECT_ICON_EXTENSIONS = ['png', 'jpg', 'svg', 'webp', 'ico'];
const readJsonFile = async (filePath) => {
try {
const raw = await fsPromises.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw);
return parsed && typeof parsed === 'object' ? parsed : null;
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
return null;
}
throw error;
}
};
const writeJsonFile = async (filePath, value) => {
await fsPromises.mkdir(path.dirname(filePath), { recursive: true });
await fsPromises.writeFile(filePath, JSON.stringify(value, null, 2), 'utf8');
};
const uniqueStrings = (values) => Array.from(new Set(values.filter((value) => typeof value === 'string' && value.trim().length > 0)));
const mergeByKey = (oldItems, newItems, getKey) => {
const result = [];
const seen = new Set();
for (const item of [...(Array.isArray(newItems) ? newItems : []), ...(Array.isArray(oldItems) ? oldItems : [])]) {
if (!item || typeof item !== 'object') continue;
const key = getKey(item);
if (!key || seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
};
const remapPlanPaths = (entries, fromDir, toDir) => {
if (!Array.isArray(entries) || !fromDir || !toDir || fromDir === toDir) {
return Array.isArray(entries) ? entries : [];
}
return entries.map((entry) => {
if (!entry || typeof entry !== 'object' || typeof entry.path !== 'string') {
return entry;
}
const trimmedPath = entry.path.trim();
if (!trimmedPath.startsWith(fromDir)) {
return entry;
}
return {
...entry,
path: `${toDir}${trimmedPath.slice(fromDir.length)}`,
};
});
};
const mergeProjectConfigData = ({ oldConfig, newConfig, oldStorageDir, newStorageDir, projectPath }) => {
const oldValue = oldConfig && typeof oldConfig === 'object' ? oldConfig : {};
const newValue = newConfig && typeof newConfig === 'object' ? newConfig : {};
const oldPlanFiles = remapPlanPaths(oldValue.projectPlanFiles, oldStorageDir, newStorageDir);
const newPlanFiles = remapPlanPaths(newValue.projectPlanFiles, oldStorageDir, newStorageDir);
const oldNotes = typeof oldValue.projectNotes === 'string' ? oldValue.projectNotes : '';
const newNotes = typeof newValue.projectNotes === 'string' ? newValue.projectNotes : '';
return {
...oldValue,
...newValue,
...(typeof projectPath === 'string' && projectPath.trim().length > 0 ? { projectPath } : {}),
...(uniqueStrings([...(Array.isArray(oldValue['setup-worktree']) ? oldValue['setup-worktree'] : []), ...(Array.isArray(newValue['setup-worktree']) ? newValue['setup-worktree'] : [])]).length > 0
? { 'setup-worktree': uniqueStrings([...(Array.isArray(oldValue['setup-worktree']) ? oldValue['setup-worktree'] : []), ...(Array.isArray(newValue['setup-worktree']) ? newValue['setup-worktree'] : [])]) }
: {}),
...(oldNotes || newNotes ? { projectNotes: newNotes || oldNotes } : {}),
...(mergeByKey(oldValue.projectTodos, newValue.projectTodos, (item) => item.id).length > 0
? { projectTodos: mergeByKey(oldValue.projectTodos, newValue.projectTodos, (item) => item.id) }
: {}),
...(mergeByKey(oldValue.projectActions, newValue.projectActions, (item) => item.id).length > 0
? { projectActions: mergeByKey(oldValue.projectActions, newValue.projectActions, (item) => item.id) }
: {}),
...(mergeByKey(oldValue.scheduledTasks, newValue.scheduledTasks, (item) => item.id).length > 0
? { scheduledTasks: mergeByKey(oldValue.scheduledTasks, newValue.scheduledTasks, (item) => item.id) }
: {}),
...(mergeByKey(oldPlanFiles, newPlanFiles, (item) => item.id || item.path).length > 0
? { projectPlanFiles: mergeByKey(oldPlanFiles, newPlanFiles, (item) => item.id || item.path) }
: {}),
...(typeof newValue.projectActionsPrimaryId === 'string' && newValue.projectActionsPrimaryId.trim().length > 0
? { projectActionsPrimaryId: newValue.projectActionsPrimaryId }
: typeof oldValue.projectActionsPrimaryId === 'string' && oldValue.projectActionsPrimaryId.trim().length > 0
? { projectActionsPrimaryId: oldValue.projectActionsPrimaryId }
: {}),
};
};
const moveDirectoryContents = async (fromDir, toDir) => {
try {
const entries = await fsPromises.readdir(fromDir, { withFileTypes: true });
await fsPromises.mkdir(toDir, { recursive: true });
for (const entry of entries) {
const fromPath = path.join(fromDir, entry.name);
const toPath = path.join(toDir, entry.name);
if (entry.isDirectory()) {
await moveDirectoryContents(fromPath, toPath);
continue;
}
try {
await fsPromises.access(toPath);
} catch {
await fsPromises.rename(fromPath, toPath);
}
}
await fsPromises.rm(fromDir, { recursive: true, force: true });
} catch (error) {
if (!(error && typeof error === 'object' && error.code === 'ENOENT')) {
throw error;
}
}
};
const migrateProjectIconFiles = async ({ oldId, newId }) => {
if (!oldId || !newId || oldId === newId) {
return;
}
const oldBase = projectIconBaseName(oldId);
const newBase = projectIconBaseName(newId);
await fsPromises.mkdir(PROJECT_ICONS_DIR, { recursive: true });
for (const ext of PROJECT_ICON_EXTENSIONS) {
const oldPath = path.join(PROJECT_ICONS_DIR, `${oldBase}.${ext}`);
const newPath = path.join(PROJECT_ICONS_DIR, `${newBase}.${ext}`);
try {
await fsPromises.access(oldPath);
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') {
continue;
}
throw error;
}
try {
await fsPromises.access(newPath);
} catch {
await fsPromises.rename(oldPath, newPath);
continue;
}
await fsPromises.rm(oldPath, { force: true });
}
};
const migrateProjectScopedStorage = async ({ oldId, newId, projectPath }) => {
if (!oldId || !newId || oldId === newId) {
return;
}
const oldConfigPath = path.join(PROJECTS_ROOT_DIR, `${oldId}.json`);
const newConfigPath = path.join(PROJECTS_ROOT_DIR, `${newId}.json`);
const oldStorageDir = path.join(PROJECTS_ROOT_DIR, oldId);
const newStorageDir = path.join(PROJECTS_ROOT_DIR, newId);
const [oldConfig, newConfig] = await Promise.all([
readJsonFile(oldConfigPath),
readJsonFile(newConfigPath),
]);
if (oldConfig || newConfig) {
const merged = mergeProjectConfigData({ oldConfig, newConfig, oldStorageDir, newStorageDir, projectPath });
await writeJsonFile(newConfigPath, merged);
}
await moveDirectoryContents(oldStorageDir, newStorageDir);
await fsPromises.rm(oldConfigPath, { force: true });
};
const migrateSettingsToDeterministicProjectIds = async (current) => {
const settings = current && typeof current === 'object' ? current : {};
const projects = sanitizeProjects(settings.projects) || [];
if (projects.length === 0) {
return { settings, changed: false };
}
let changed = false;
const projectIdMap = new Map();
const nextProjects = [];
for (const project of projects) {
const canonicalId = createProjectIdFromPath(project.path);
const nextId = canonicalId || project.id;
projectIdMap.set(project.id, nextId);
if (nextId !== project.id) {
changed = true;
await migrateProjectScopedStorage({ oldId: project.id, newId: nextId, projectPath: project.path });
await migrateProjectIconFiles({ oldId: project.id, newId: nextId });
}
nextProjects.push({ ...project, id: nextId });
}
try {
await recoverOrphanProjectFiles(nextProjects);
} catch (error) {
console.warn('[projects] Orphan recovery failed, continuing startup:', error);
}
if (!changed) {
return { settings, changed: false };
}
const currentActiveId = typeof settings.activeProjectId === 'string' ? settings.activeProjectId : '';
const nextActiveProjectId = projectIdMap.get(currentActiveId) || currentActiveId || nextProjects[0]?.id;
return {
settings: {
...settings,
projects: nextProjects,
...(nextActiveProjectId ? { activeProjectId: nextActiveProjectId } : {}),
},
changed: true,
};
};
// Orphan files are project jsons left behind from earlier random-UUID project
// ids (they have no projectPath field and are not referenced by settings).
// For each canonical project whose current config is empty (lost during the
// earlier id churn), try to find a single orphan whose setup-worktree command
// patterns uniquely match the project's basename and merge it in.
const recoverOrphanProjectFiles = async (canonicalProjects) => {
let entries;
try {
entries = await fsPromises.readdir(PROJECTS_ROOT_DIR, { withFileTypes: true });
} catch (error) {
if (error && typeof error === 'object' && error.code === 'ENOENT') return;
throw error;
}
const canonicalIds = new Set(canonicalProjects.map((project) => project.id));
const orphanFiles = entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
.map((entry) => entry.name.replace(/\.json$/, ''))
.filter((id) => id && !id.startsWith('path_') && !canonicalIds.has(id));
if (orphanFiles.length === 0) return;
console.warn(`[projects] Found ${orphanFiles.length} orphan project config(s) without projectPath.`);
const orphans = [];
for (const orphanId of orphanFiles) {
const filePath = path.join(PROJECTS_ROOT_DIR, `${orphanId}.json`);
const content = await readJsonFile(filePath);
if (!content) continue;
const hasContent = [
typeof content.projectNotes === 'string' && content.projectNotes.trim().length > 0,
Array.isArray(content.projectTodos) && content.projectTodos.length > 0,
Array.isArray(content.projectActions) && content.projectActions.length > 0,
Array.isArray(content['setup-worktree']) && content['setup-worktree'].length > 0,
Array.isArray(content.projectPlanFiles) && content.projectPlanFiles.length > 0,
].some(Boolean);
if (!hasContent) continue;
orphans.push({ orphanId, filePath, content });
}
if (orphans.length === 0) return;
const basenameOf = (projectPath) => {
if (typeof projectPath !== 'string') return '';
const normalized = projectPath.replace(/\\/g, '/').replace(/\/+$/g, '');
const idx = normalized.lastIndexOf('/');
return (idx >= 0 ? normalized.slice(idx + 1) : normalized).toLowerCase();
};
const extractRootRelPaths = (orphan) => {
const commands = [
...(Array.isArray(orphan.content['setup-worktree']) ? orphan.content['setup-worktree'] : []),
...(Array.isArray(orphan.content.projectActions) ? orphan.content.projectActions.map((a) => typeof a?.command === 'string' ? a.command : '') : []),
].filter((s) => typeof s === 'string');
const results = new Set();
const re = /\$(?:\{)?ROOT_(?:PROJECT|WORKTREE)_PATH\}?\/([A-Za-z0-9._/-]+)/g;
for (const cmd of commands) {
let match;
while ((match = re.exec(cmd)) !== null) {
results.add(match[1]);
}
}
return Array.from(results);
};
const fileExistsInProject = async (projectPath, relPath) => {
try {
await fsPromises.access(path.join(projectPath, relPath));
return true;
} catch {
return false;
}
};
const orphanMatchesProject = async (orphan, project) => {
if (typeof project.path !== 'string' || !project.path.trim()) return false;
const rels = extractRootRelPaths(orphan);
for (const rel of rels) {
if (await fileExistsInProject(project.path, rel)) {
return true;
}
}
const name = basenameOf(project.path);
if (!name) return false;
const haystacks = [
...(Array.isArray(orphan.content['setup-worktree']) ? orphan.content['setup-worktree'] : []),
...(Array.isArray(orphan.content.projectActions) ? orphan.content.projectActions.map((a) => `${a?.name || ''} ${a?.command || ''}`) : []),
].join(' ').toLowerCase();
return haystacks.includes(name);
};
const matches = new Map();
for (const orphan of orphans) {
const matchedProjects = [];
for (const project of canonicalProjects) {
if (await orphanMatchesProject(orphan, project)) {
matchedProjects.push(project);
}
}
if (matchedProjects.length === 1) {
const project = matchedProjects[0];
const list = matches.get(project.id) || [];
list.push(orphan);
matches.set(project.id, list);
}
}
const orphansConsumed = new Set();
for (const [projectId, orphansForProject] of matches.entries()) {
const project = canonicalProjects.find((p) => p.id === projectId);
if (!project) continue;
const targetPath = path.join(PROJECTS_ROOT_DIR, `${project.id}.json`);
for (const orphan of orphansForProject) {
const targetExisting = (await readJsonFile(targetPath)) || {};
const merged = mergeProjectConfigData({
oldConfig: orphan.content,
newConfig: targetExisting,
oldStorageDir: path.join(PROJECTS_ROOT_DIR, orphan.orphanId),
newStorageDir: path.join(PROJECTS_ROOT_DIR, project.id),
projectPath: project.path,
});
await writeJsonFile(targetPath, merged);
await moveDirectoryContents(path.join(PROJECTS_ROOT_DIR, orphan.orphanId), path.join(PROJECTS_ROOT_DIR, project.id));
await fsPromises.rm(orphan.filePath, { force: true });
orphansConsumed.add(orphan.orphanId);
console.log(`[projects] Recovered orphan ${orphan.orphanId} -> ${project.id} (${project.path})`);
}
}
const remaining = orphans.filter((orphan) => !orphansConsumed.has(orphan.orphanId));
if (remaining.length > 0) {
console.warn(`[projects] ${remaining.length} orphan project file(s) could not be auto-matched: ${remaining.map((o) => o.orphanId).join(', ')}`);
}
};
const readSettingsFromDisk = async () => {
try {
const raw = await fsPromises.readFile(SETTINGS_FILE_PATH, 'utf8');
@@ -130,7 +493,7 @@ export const createSettingsRuntime = (deps) => {
try {
const stats = await fsPromises.stat(candidate);
if (stats.isDirectory()) {
const id = crypto.randomUUID();
const id = createProjectIdFromPath(candidate);
nextProjects = [
{
id,
@@ -358,10 +721,11 @@ export const createSettingsRuntime = (deps) => {
const migration4 = await migrateSettingsNotificationDefaults(migration3.settings);
const migration5 = await migrateSettingsFromLegacyNamedTunnelKeys(migration4.settings);
const migration6 = normalizeSettingsPaths(migration5.settings);
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed) {
await writeSettingsToDisk(migration6.settings);
const migration7 = await migrateSettingsToDeterministicProjectIds(migration6.settings);
if (migration1.changed || migration2.changed || migration3.changed || migration4.changed || migration5.changed || migration6.changed || migration7.changed) {
await writeSettingsToDisk(migration7.settings);
}
return migration6.settings;
return migration7.settings;
};
const persistSettings = async (changes) => {
@@ -377,6 +741,11 @@ export const createSettingsRuntime = (deps) => {
next = normalizedState.settings;
}
const deterministicProjectIdMigration = await migrateSettingsToDeterministicProjectIds(next);
if (deterministicProjectIdMigration.changed) {
next = deterministicProjectIdMigration.settings;
}
if (Array.isArray(next.projects)) {
console.log(`[persistSettings] Validating ${next.projects.length} projects...`);
const validated = await validateProjectEntries(next.projects);
@@ -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);
};
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'bun:test';
import os from 'os';
import path from 'path';
import { mkdtemp, rm } from 'fs/promises';
import { mkdtemp, rm, readFile, writeFile } from 'fs/promises';
import { createProjectConfigRuntime } from './project-config.js';
const createRuntime = async () => {
@@ -73,6 +73,47 @@ describe('project-config runtime', () => {
}
});
it('preserves unknown project config keys when writing scheduled tasks', async () => {
const { runtime, cleanup } = await createRuntime();
try {
const projectID = 'path_preserve';
const filePath = path.join(runtime.resolveProjectConfigPath(projectID));
await writeFile(
filePath,
JSON.stringify({
projectNotes: 'hello notes',
projectTodos: [{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }],
projectActions: [{ id: 'a1', name: 'Run', command: 'bun run dev' }],
projectActionsPrimaryId: 'a1',
'setup-worktree': ['bun install'],
projectPlanFiles: [{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }],
projectPath: '/tmp/demo',
}, null, 2),
'utf8',
);
await runtime.upsertScheduledTask(projectID, {
name: 'nightly',
enabled: true,
schedule: { kind: 'daily', time: '09:00', timezone: 'UTC' },
execution: { prompt: 'run', providerID: 'openai', modelID: 'gpt-4.1' },
});
const raw = JSON.parse(await readFile(filePath, 'utf8'));
expect(raw.projectNotes).toBe('hello notes');
expect(raw.projectTodos).toEqual([{ id: 't1', text: 'buy milk', completed: false, createdAt: 1 }]);
expect(raw.projectActions).toHaveLength(1);
expect(raw.projectActionsPrimaryId).toBe('a1');
expect(raw['setup-worktree']).toEqual(['bun install']);
expect(raw.projectPlanFiles).toEqual([{ id: 'p1', path: '/tmp/plans/p1.md', createdAt: 2 }]);
expect(raw.projectPath).toBe('/tmp/demo');
expect(raw.scheduledTasks).toHaveLength(1);
expect(raw.version).toBe(1);
} finally {
await cleanup();
}
});
it('accepts one-time schedule with date and time', async () => {
const { runtime, cleanup } = await createRuntime();
try {
@@ -0,0 +1,13 @@
const normalizeProjectPathForId = (value) => {
if (typeof value !== 'string') return '';
return value.replace(/\\/g, '/').replace(/\/+$/g, '') || value;
};
export const createProjectIdFromPath = (projectPath) => {
const normalized = normalizeProjectPathForId(projectPath).trim();
if (!normalized) {
return '';
}
return `path_${Buffer.from(normalized, 'utf8').toString('base64url')}`;
};