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
+11 -18
View File
@@ -4,6 +4,7 @@ import { opencodeClient } from '@/lib/opencode/client';
import type { ProjectEntry } from '@/lib/api/types';
import type { DesktopSettings } from '@/lib/desktop';
import { updateDesktopSettings } from '@/lib/persistence';
import { createProjectIdFromPath } from '@/lib/projectId';
import { getSafeStorage } from './utils/safeStorage';
import { useDirectoryStore } from './useDirectoryStore';
import { streamDebugEnabled } from '@/stores/utils/streamDebug';
@@ -112,13 +113,6 @@ const deriveProjectLabel = (path: string): string => {
return raw.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
const createProjectId = (): string => {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `proj_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
};
const sanitizeProjectIconImage = (value: unknown): ProjectEntry['iconImage'] | undefined => {
if (!value || typeof value !== 'object') {
return undefined;
@@ -185,13 +179,15 @@ const sanitizeProjects = (value: unknown): ProjectEntry[] => {
if (!entry || typeof entry !== 'object') continue;
const candidate = entry as Record<string, unknown>;
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const rawPath = typeof candidate.path === 'string' ? candidate.path.trim() : '';
if (!id || !rawPath) continue;
if (!rawPath) continue;
const normalizedPath = normalizeProjectPath(rawPath);
if (!normalizedPath) continue;
const id = createProjectIdFromPath(normalizedPath);
if (!id) continue;
if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue;
seenIds.add(id);
seenPaths.add(normalizedPath);
@@ -310,7 +306,7 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI
return null;
}
const id = `vscode:${normalizedPath}`;
const id = createProjectIdFromPath(normalizedPath);
const entry: ProjectEntry = {
id,
path: normalizedPath,
@@ -330,10 +326,10 @@ const getVSCodeWorkspaceProject = (): { projects: ProjectEntry[]; activeProjectI
// Always prefer the workspace project over any persisted multi-project registry.
const vscodeWorkspace = getVSCodeWorkspaceProject();
const effectiveInitialProjects = vscodeWorkspace?.projects ?? initialProjects;
const initialActiveProjectId = vscodeWorkspace?.activeProjectId
?? readPersistedActiveProjectId()
?? effectiveInitialProjects[0]?.id
?? null;
const persistedInitialActiveProjectId = vscodeWorkspace?.activeProjectId ?? readPersistedActiveProjectId();
const initialActiveProjectId = effectiveInitialProjects.some((project) => project.id === persistedInitialActiveProjectId)
? persistedInitialActiveProjectId
: effectiveInitialProjects[0]?.id ?? null;
if (vscodeWorkspace) {
cacheProjects(effectiveInitialProjects, initialActiveProjectId);
@@ -376,10 +372,7 @@ export const useProjectsStore = create<ProjectsStore>()(
const now = Date.now();
const label = options?.label?.trim() || deriveProjectLabel(normalizedPath);
const candidateId = options?.id?.trim();
const id = candidateId && !get().projects.some((project) => project.id === candidateId)
? candidateId
: createProjectId();
const id = createProjectIdFromPath(normalizedPath);
const entry: ProjectEntry = {
id,
path: normalizedPath,