From 4901cf60b8ee68a1757bbf9524cb84719ee33047 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 18 Apr 2026 13:46:38 +0300 Subject: [PATCH] fix(projects): deterministic project identity + safe per-project persistence Derive project.id from project.path (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/ 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. --- packages/ui/src/lib/openchamberConfig.ts | 348 +++++++++++----- packages/ui/src/lib/persistence.ts | 7 +- packages/ui/src/lib/projectId.ts | 18 + packages/ui/src/stores/useProjectsStore.ts | 29 +- packages/web/server/lib/opencode/routes.js | 4 +- .../server/lib/opencode/settings-runtime.js | 377 +++++++++++++++++- .../web/server/lib/projects/project-config.js | 61 +-- .../lib/projects/project-config.test.js | 43 +- .../web/server/lib/projects/project-id.js | 13 + 9 files changed, 753 insertions(+), 147 deletions(-) create mode 100644 packages/ui/src/lib/projectId.ts create mode 100644 packages/web/server/lib/projects/project-id.js diff --git a/packages/ui/src/lib/openchamberConfig.ts b/packages/ui/src/lib/openchamberConfig.ts index b9889023..727c9174 100644 --- a/packages/ui/src/lib/openchamberConfig.ts +++ b/packages/ui/src/lib/openchamberConfig.ts @@ -7,41 +7,14 @@ import type { FilesAPI, RuntimeAPIs } from './api/types'; import { getDesktopHomeDirectory } from './desktop'; import { isVSCodeRuntime } from './desktop'; +import { createProjectIdFromPath } from './projectId'; type ProjectRef = { id: string; path: string }; const CONFIG_FILENAME = 'openchamber.json'; // LEGACY_PROJECT_CONFIG: legacy per-project config root inside repo. const LEGACY_CONFIG_DIR = '.openchamber'; -const USER_CONFIG_DIR_SEGMENTS = ['.config', 'openchamber']; const USER_PROJECTS_DIR_SEGMENTS = ['.config', 'openchamber', 'projects']; -const SETTINGS_FILENAME = 'settings.json'; - -const projectIdCache = new Map(); - -const isSafeConfigFileId = (value: string): boolean => /^[A-Za-z0-9._-]+$/.test(value); - -const toHex = (bytes: Uint8Array): string => { - let out = ''; - for (const b of bytes) { - out += b.toString(16).padStart(2, '0'); - } - return out; -}; - -const sha1Hex = async (value: string): Promise => { - try { - if (typeof crypto === 'undefined' || !crypto.subtle) { - return null; - } - const encoder = new TextEncoder(); - const data = encoder.encode(value); - const digest = await crypto.subtle.digest('SHA-1', data); - return toHex(new Uint8Array(digest)); - } catch { - return null; - } -}; /** * Get the runtime Files API if available (Desktop/VSCode). @@ -56,9 +29,11 @@ function getRuntimeFilesAPI(): FilesAPI | null { } export interface OpenChamberConfig { + projectPath?: string; 'setup-worktree'?: string[]; projectNotes?: string; projectTodos?: OpenChamberProjectTodoItem[]; + projectPlanFiles?: OpenChamberProjectPlanFileLink[]; projectActions?: OpenChamberProjectAction[]; projectActionsPrimaryId?: string; } @@ -88,17 +63,35 @@ export interface OpenChamberProjectTodoItem { createdAt: number; } +export interface OpenChamberProjectPlanFileLink { + id: string; + path: string; + createdAt: number; +} + +export interface OpenChamberProjectPlanFile { + title: string; + body: string; + raw: string; + path: string; +} + export interface OpenChamberProjectNotesTodos { notes: string; todos: OpenChamberProjectTodoItem[]; } +export interface OpenChamberProjectContextData extends OpenChamberProjectNotesTodos { + plans: OpenChamberProjectPlanFileLink[]; +} + export const OPENCHAMBER_PROJECT_NOTES_MAX_LENGTH = 1000; export const OPENCHAMBER_PROJECT_TODO_TEXT_MAX_LENGTH = 120; export const OPENCHAMBER_PROJECT_ACTION_NAME_MAX_LENGTH = 80; export const OPENCHAMBER_PROJECT_ACTION_COMMAND_MAX_LENGTH = 4000; export const OPENCHAMBER_PROJECT_ACTION_OPEN_URL_MAX_LENGTH = 2000; export const OPENCHAMBER_PROJECT_ACTION_DESKTOP_FORWARD_MAX_LENGTH = 300; +export const OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH = 160; const OPENCHAMBER_ACTION_PLATFORM_SET = new Set(['macos', 'linux', 'windows']); @@ -226,14 +219,6 @@ const resolveHomeDirectory = async (): Promise => { } }; -const getUserConfigRootDirectory = async (): Promise => { - const home = await resolveHomeDirectory(); - if (!home) { - return null; - } - return USER_CONFIG_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home); -}; - const getUserProjectsDirectory = async (): Promise => { const home = await resolveHomeDirectory(); if (!home) { @@ -242,63 +227,11 @@ const getUserProjectsDirectory = async (): Promise => { return USER_PROJECTS_DIR_SEGMENTS.reduce((acc, segment) => joinPath(acc, segment), home); }; -const getSettingsPath = async (): Promise => { - const base = await getUserConfigRootDirectory(); - if (!base) { - return null; - } - return joinPath(base, SETTINGS_FILENAME); -}; - -const resolveConfigProjectId = async (project: ProjectRef): Promise => { +const resolveConfigProjectId = (project: ProjectRef): string | null => { const projectDirectory = typeof project?.path === 'string' ? project.path.trim() : ''; const normalizedProject = projectDirectory ? normalize(projectDirectory) : ''; - - const explicitId = typeof project?.id === 'string' ? project.id.trim() : ''; - if (explicitId && isSafeConfigFileId(explicitId)) { - return explicitId; - } - - if (normalizedProject) { - const cached = projectIdCache.get(normalizedProject); - if (cached) { - return cached; - } - } - - // Best-effort map project directory -> persisted project id from settings.json. - const settingsPath = await getSettingsPath(); - if (settingsPath && normalizedProject) { - const raw = await readTextFile(settingsPath); - if (raw) { - try { - const parsed = JSON.parse(raw) as { projects?: unknown }; - const projects = Array.isArray(parsed?.projects) ? parsed.projects : []; - for (const entry of projects) { - if (!entry || typeof entry !== 'object') continue; - const record = entry as { id?: unknown; path?: unknown }; - const id = typeof record.id === 'string' ? record.id.trim() : ''; - const path = typeof record.path === 'string' ? normalize(record.path.trim()) : ''; - if (id && isSafeConfigFileId(id) && path && path === normalizedProject) { - projectIdCache.set(normalizedProject, id); - return id; - } - } - } catch { - // ignore - } - } - } - - // Fallback: stable id derived from path (used in VSCode when project isn't registered). - if (normalizedProject) { - const digest = await sha1Hex(normalizedProject); - const fallback = digest ? `path_${digest}` : `path_${normalizedProject.replace(/[^A-Za-z0-9._-]+/g, '_')}`; - projectIdCache.set(normalizedProject, fallback); - return fallback; - } - - return null; + if (!normalizedProject) return null; + return createProjectIdFromPath(normalizedProject) || null; }; const getUserConfigPath = async (project: ProjectRef): Promise => { @@ -306,7 +239,7 @@ const getUserConfigPath = async (project: ProjectRef): Promise => if (!base) { return null; } - const safeId = await resolveConfigProjectId(project); + const safeId = resolveConfigProjectId(project); if (!safeId) { return null; } @@ -370,6 +303,43 @@ const sanitizeProjectTodoItems = (value: unknown): OpenChamberProjectTodoItem[] return sanitized; }; +const sanitizeProjectPlanFileLinks = (value: unknown): OpenChamberProjectPlanFileLink[] => { + if (!Array.isArray(value)) { + return []; + } + + const sanitized: OpenChamberProjectPlanFileLink[] = []; + const seenIds = new Set(); + + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue; + } + + const record = entry as { + id?: unknown; + path?: unknown; + createdAt?: unknown; + }; + + const id = typeof record.id === 'string' ? record.id.trim() : ''; + const path = typeof record.path === 'string' ? record.path.trim() : ''; + const createdAt = + typeof record.createdAt === 'number' && Number.isFinite(record.createdAt) && record.createdAt >= 0 + ? record.createdAt + : Date.now(); + + if (!id || !path || seenIds.has(id)) { + continue; + } + + seenIds.add(id); + sanitized.push({ id, path, createdAt }); + } + + return sanitized.sort((a, b) => b.createdAt - a.createdAt); +}; + const sanitizeProjectActionPlatforms = (value: unknown): OpenChamberProjectActionPlatform[] => { if (!Array.isArray(value)) { return []; @@ -479,6 +449,87 @@ const sanitizeProjectNotesAndTodos = (value: { }; }; +const sanitizeProjectContextData = (value: { + notes?: unknown; + todos?: unknown; + plans?: unknown; +} | null | undefined): OpenChamberProjectContextData => { + const notesAndTodos = sanitizeProjectNotesAndTodos(value); + return { + ...notesAndTodos, + plans: sanitizeProjectPlanFileLinks(value?.plans), + }; +}; + +const slugifyPlanTitle = (value: string): string => { + const normalized = value + .trim() + .toLowerCase() + .replace(/[`*_#>[\](){}.!?,:;"']/g, '') + .replace(/\s+/g, '-') + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-+|-+$/g, ''); + + return normalized || 'plan'; +}; + +const sanitizePlanTitle = (value: string): string => { + return trimToMaxLength(value.trim(), OPENCHAMBER_PROJECT_PLAN_TITLE_MAX_LENGTH); +}; + +const createProjectPlanId = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `plan_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; +}; + +const getProjectStorageDirectory = async (project: ProjectRef): Promise => { + const base = await getUserProjectsDirectory(); + const safeId = resolveConfigProjectId(project); + if (!base || !safeId) { + return null; + } + return joinPath(base, safeId); +}; + +const getProjectPlansDirectory = async (project: ProjectRef): Promise => { + const projectDirectory = await getProjectStorageDirectory(project); + if (!projectDirectory) { + return null; + } + return joinPath(projectDirectory, 'plans'); +}; + +export const formatProjectPlanMarkdown = (title: string, body: string): string => { + const normalizedTitle = sanitizePlanTitle(title) || 'Plan'; + const normalizedBody = body.trim(); + return normalizedBody + ? `# ${normalizedTitle}\n\n${normalizedBody}` + : `# ${normalizedTitle}\n`; +}; + +export const parseProjectPlanMarkdown = (raw: string): { title: string; body: string } => { + const text = typeof raw === 'string' ? raw : ''; + const normalized = text.replace(/\r\n?/g, '\n'); + const match = normalized.match(/^\s*#\s+(.+?)\s*(?:\n+|$)/); + if (match) { + const title = sanitizePlanTitle(match[1]); + const body = normalized.slice(match[0].length).replace(/^\n+/, ''); + return { + title: title || 'Plan', + body, + }; + } + + const firstNonEmptyLine = normalized.split('\n').map((line) => line.trim()).find(Boolean) || 'Plan'; + return { + title: sanitizePlanTitle(firstNonEmptyLine.replace(/^#+\s*/, '')) || 'Plan', + body: normalized.trim(), + }; +}; + /** * Read the config for a project. * Returns null if file doesn't exist or is invalid. @@ -550,6 +601,10 @@ export async function readOpenChamberConfig(project: ProjectRef): Promise = {}; + if (typeof existingRaw === 'string' && existingRaw.trim()) { + try { + const parsed = JSON.parse(existingRaw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + existing = parsed as Record; + } + } catch { + existing = {}; + } + } + + const serverOwned: Record = {}; + if (existing.version !== undefined) serverOwned.version = existing.version; + if (existing.scheduledTasks !== undefined) serverOwned.scheduledTasks = existing.scheduledTasks; + + const content = JSON.stringify({ + ...existing, + ...config, + ...serverOwned, + projectPath: normalize(projectDirectory), + }, null, 2); return await writeTextFile(configPath, content); } catch (error) { console.error('Failed to write openchamber config:', error); @@ -629,6 +705,90 @@ export async function saveProjectNotesAndTodos( }); } +export async function getProjectContextData(project: ProjectRef): Promise { + const config = await readOpenChamberConfig(project); + return sanitizeProjectContextData({ + notes: config?.projectNotes, + todos: config?.projectTodos, + plans: config?.projectPlanFiles, + }); +} + +export async function getProjectPlanFiles(project: ProjectRef): Promise { + const config = await readOpenChamberConfig(project); + return sanitizeProjectPlanFileLinks(config?.projectPlanFiles); +} + +export async function saveProjectPlanFiles( + project: ProjectRef, + value: OpenChamberProjectPlanFileLink[] +): Promise { + const sanitized = sanitizeProjectPlanFileLinks(value); + return updateOpenChamberConfig(project, { + projectPlanFiles: sanitized, + }); +} + +export async function readProjectPlanFile(path: string): Promise { + const trimmedPath = typeof path === 'string' ? path.trim() : ''; + if (!trimmedPath) { + return null; + } + + const raw = await readTextFile(trimmedPath); + if (raw === null) { + return null; + } + + const parsed = parseProjectPlanMarkdown(raw); + return { + title: parsed.title, + body: parsed.body, + raw, + path: trimmedPath, + }; +} + +export async function createProjectPlanFile( + project: ProjectRef, + value: { title: string; body: string } +): Promise { + const plansDirectory = await getProjectPlansDirectory(project); + if (!plansDirectory) { + return null; + } + + const title = sanitizePlanTitle(value.title) || 'Plan'; + const createdAt = Date.now(); + const id = createProjectPlanId(); + const filePath = joinPath(plansDirectory, `${createdAt}-${slugifyPlanTitle(title)}.md`); + + const projectDirectory = await getProjectStorageDirectory(project); + if (!projectDirectory) { + return null; + } + + const createdProjectDir = await mkdirp(projectDirectory); + const createdPlansDir = createdProjectDir ? await mkdirp(plansDirectory) : false; + if (!createdProjectDir || !createdPlansDir) { + return null; + } + + const wrote = await writeTextFile(filePath, formatProjectPlanMarkdown(title, value.body)); + if (!wrote) { + return null; + } + + const existing = await getProjectPlanFiles(project); + const nextEntry = { id, path: filePath, createdAt }; + const saved = await saveProjectPlanFiles(project, [nextEntry, ...existing]); + if (!saved) { + return null; + } + + return nextEntry; +} + export async function getProjectActionsState(project: ProjectRef): Promise { const config = await readOpenChamberConfig(project); return sanitizeProjectActionsState({ diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 6db50f1c..620f21d3 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -1,4 +1,5 @@ import type { DesktopSettings } from '@/lib/desktop'; +import { createProjectIdFromPath } from '@/lib/projectId'; import { useUIStore } from '@/stores/useUIStore'; import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { setDirectoryShowHidden } from '@/lib/directoryShowHidden'; @@ -149,13 +150,15 @@ const sanitizeProjects = (value: unknown): DesktopSettings['projects'] | undefin if (!entry || typeof entry !== 'object') continue; const candidate = entry as Record; - 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 = rawPath === '/' ? rawPath : rawPath.replace(/\\/g, '/').replace(/\/+$/, ''); if (!normalizedPath) continue; + const id = createProjectIdFromPath(normalizedPath); + if (!id) continue; + if (seenIds.has(id) || seenPaths.has(normalizedPath)) continue; seenIds.add(id); seenPaths.add(normalizedPath); diff --git a/packages/ui/src/lib/projectId.ts b/packages/ui/src/lib/projectId.ts new file mode 100644 index 00000000..f6070c3b --- /dev/null +++ b/packages/ui/src/lib/projectId.ts @@ -0,0 +1,18 @@ +export const createProjectIdFromPath = (projectPath: string): string => { + const normalized = projectPath.replace(/\\/g, '/').replace(/\/+$/g, '').trim(); + if (!normalized) { + return ''; + } + + const data = new TextEncoder().encode(normalized); + let binary = ''; + for (const byte of data) { + binary += String.fromCharCode(byte); + } + + const encoded = typeof btoa === 'function' + ? btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '') + : normalized.replace(/[^A-Za-z0-9._-]+/g, '_'); + + return `path_${encoded}`; +}; diff --git a/packages/ui/src/stores/useProjectsStore.ts b/packages/ui/src/stores/useProjectsStore.ts index 4980767f..cd079f88 100644 --- a/packages/ui/src/stores/useProjectsStore.ts +++ b/packages/ui/src/stores/useProjectsStore.ts @@ -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; - 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()( 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, diff --git a/packages/web/server/lib/opencode/routes.js b/packages/web/server/lib/opencode/routes.js index 3dde098e..8b964ea3 100644 --- a/packages/web/server/lib/opencode/routes.js +++ b/packages/web/server/lib/opencode/routes.js @@ -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(), diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index a57124ec..b9ff7126 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -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); diff --git a/packages/web/server/lib/projects/project-config.js b/packages/web/server/lib/projects/project-config.js index 0c8c68b1..cbc2df77 100644 --- a/packages/web/server/lib/projects/project-config.js +++ b/packages/web/server/lib/projects/project-config.js @@ -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); }; diff --git a/packages/web/server/lib/projects/project-config.test.js b/packages/web/server/lib/projects/project-config.test.js index cc2809c1..6d53e2a4 100644 --- a/packages/web/server/lib/projects/project-config.test.js +++ b/packages/web/server/lib/projects/project-config.test.js @@ -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 { diff --git a/packages/web/server/lib/projects/project-id.js b/packages/web/server/lib/projects/project-id.js new file mode 100644 index 00000000..e0a3e1cb --- /dev/null +++ b/packages/web/server/lib/projects/project-id.js @@ -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')}`; +};