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:
@@ -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')}`;
|
||||
};
|
||||
Reference in New Issue
Block a user