feat: add coding project schema v2 migration
This commit is contained in:
318
electron/coding-projects/migration.ts
Normal file
318
electron/coding-projects/migration.ts
Normal file
@@ -0,0 +1,318 @@
|
||||
import {
|
||||
copyFile,
|
||||
mkdir,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
unlink,
|
||||
} from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { ProductModelRef } from '../coding-runtime/contracts';
|
||||
import {
|
||||
buildProjectAgentManifest,
|
||||
normalizeProjectConfig,
|
||||
} from '../opencode/project-config';
|
||||
import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config';
|
||||
import {
|
||||
atomicWriteJson,
|
||||
atomicWriteText,
|
||||
type JsonFileWriter,
|
||||
} from './atomic-json';
|
||||
import {
|
||||
CODING_PROJECT_CONFIG_PATH,
|
||||
normalizeCodingProjectConfigV2,
|
||||
type CodingProjectAgentV2,
|
||||
type CodingProjectConfigV2,
|
||||
} from './project-config';
|
||||
import {
|
||||
CODING_CONVERSATIONS_PATH,
|
||||
createEmptyConversationFileV2,
|
||||
} from './conversation-store';
|
||||
|
||||
export interface LegacyModelResolutionInput {
|
||||
agentId: string;
|
||||
legacyModel: string;
|
||||
}
|
||||
|
||||
export type LegacyModelResolver = (
|
||||
input: LegacyModelResolutionInput,
|
||||
) => Promise<ProductModelRef | null>;
|
||||
|
||||
export interface CodingProjectMigrationDependencies {
|
||||
resolveLegacyModel: LegacyModelResolver;
|
||||
now?: () => string;
|
||||
writeJson?: JsonFileWriter;
|
||||
copyFile?: typeof copyFile;
|
||||
}
|
||||
|
||||
export type CodingProjectMigrationResult =
|
||||
| {
|
||||
status: 'already-current';
|
||||
config: CodingProjectConfigV2;
|
||||
}
|
||||
| {
|
||||
status: 'migrated';
|
||||
config: CodingProjectConfigV2;
|
||||
backupDirectory: string;
|
||||
removedGeneratedAgents: string[];
|
||||
backedUpUncertainAgents: string[];
|
||||
};
|
||||
|
||||
type AgentFileSnapshot = {
|
||||
fileName: string;
|
||||
filePath: string;
|
||||
content: string;
|
||||
generated: boolean;
|
||||
backupPath?: string;
|
||||
};
|
||||
|
||||
function isMissing(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException).code === 'ENOENT';
|
||||
}
|
||||
|
||||
function timestampKey(value: string): string {
|
||||
return value.replace(/[^0-9A-Za-z_-]/g, '-');
|
||||
}
|
||||
|
||||
async function readOptionalText(filePath: string): Promise<string | null> {
|
||||
try {
|
||||
return await readFile(filePath, 'utf8');
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshotLegacyAgentFiles(
|
||||
projectPath: string,
|
||||
config: ProjectConfig,
|
||||
backupDirectory: string,
|
||||
copy: typeof copyFile,
|
||||
): Promise<AgentFileSnapshot[]> {
|
||||
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(agentDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (isMissing(error)) return [];
|
||||
throw error;
|
||||
}
|
||||
|
||||
const expected = new Map(buildProjectAgentManifest(config).entries.map((entry) => [
|
||||
path.basename(entry.relativePath),
|
||||
entry.content,
|
||||
]));
|
||||
const snapshots: AgentFileSnapshot[] = [];
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
const filePath = path.join(agentDirectory, entry.name);
|
||||
const content = await readFile(filePath, 'utf8');
|
||||
const generated = expected.get(entry.name) === content;
|
||||
if (generated) {
|
||||
snapshots.push({ fileName: entry.name, filePath, content, generated });
|
||||
continue;
|
||||
}
|
||||
const backupPath = path.join(backupDirectory, '.opencode', 'agent', entry.name);
|
||||
await mkdir(path.dirname(backupPath), { recursive: true });
|
||||
await copy(filePath, backupPath);
|
||||
snapshots.push({ fileName: entry.name, filePath, content, generated, backupPath });
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
function legacyModelForAgent(config: ProjectConfig, agent: ProjectAgentConfig): string | null {
|
||||
const direct = typeof agent.model === 'string' ? agent.model.trim() : '';
|
||||
const fallback = typeof config.defaultModel === 'string' ? config.defaultModel.trim() : '';
|
||||
return direct || fallback || null;
|
||||
}
|
||||
|
||||
function preserveLegacyAgentFields(
|
||||
rawProject: unknown,
|
||||
normalized: ProjectConfig,
|
||||
): ProjectConfig {
|
||||
if (!rawProject || typeof rawProject !== 'object' || Array.isArray(rawProject)) return normalized;
|
||||
const rawAgents = (rawProject as { agents?: unknown }).agents;
|
||||
if (!Array.isArray(rawAgents)) return normalized;
|
||||
const rawById = new Map<string, { prompt?: unknown; skillIds?: unknown }>();
|
||||
for (const value of rawAgents) {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) continue;
|
||||
const record = value as { id?: unknown; prompt?: unknown; skillIds?: unknown };
|
||||
const id = typeof record.id === 'string' ? record.id.trim() : '';
|
||||
if (id) rawById.set(id, record);
|
||||
}
|
||||
return {
|
||||
...normalized,
|
||||
agents: normalized.agents.map((agent) => {
|
||||
const raw = rawById.get(agent.id);
|
||||
const rawSkillIds = Array.isArray(raw?.skillIds)
|
||||
? [...new Set(raw.skillIds
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean))]
|
||||
: agent.skillIds;
|
||||
return {
|
||||
...agent,
|
||||
prompt: typeof raw?.prompt === 'string' ? raw.prompt : agent.prompt,
|
||||
skillIds: rawSkillIds,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function migrateAgent(
|
||||
config: ProjectConfig,
|
||||
agent: ProjectAgentConfig,
|
||||
resolveLegacyModel: LegacyModelResolver,
|
||||
fallbackTimestamp: string,
|
||||
): Promise<CodingProjectAgentV2> {
|
||||
const legacyModel = legacyModelForAgent(config, agent);
|
||||
const model = legacyModel
|
||||
? await resolveLegacyModel({ agentId: agent.id, legacyModel })
|
||||
: null;
|
||||
return {
|
||||
id: agent.id,
|
||||
avatarId: agent.avatarId,
|
||||
...(agent.avatarDataUrl ? { avatarDataUrl: agent.avatarDataUrl } : {}),
|
||||
roleName: agent.roleName,
|
||||
name: agent.name,
|
||||
builtIn: agent.builtIn,
|
||||
enabled: agent.enabled,
|
||||
model,
|
||||
modelResolution: model ? 'resolved' : 'required',
|
||||
skillIds: [...agent.skillIds],
|
||||
responsibility: {
|
||||
mission: agent.responsibility.mission,
|
||||
owns: [...agent.responsibility.owns],
|
||||
boundaries: [...agent.responsibility.boundaries],
|
||||
collaborators: [...agent.responsibility.collaborators],
|
||||
principles: [...agent.responsibility.principles],
|
||||
},
|
||||
prompt: agent.prompt,
|
||||
archivedAt: agent.archivedAt ?? null,
|
||||
pinned: agent.pinned === true,
|
||||
createdAt: agent.createdAt ?? fallbackTimestamp,
|
||||
updatedAt: agent.updatedAt ?? fallbackTimestamp,
|
||||
};
|
||||
}
|
||||
|
||||
async function restoreOriginalState(
|
||||
projectConfigPath: string,
|
||||
projectSource: string,
|
||||
conversationsPath: string,
|
||||
conversationsSource: string | null,
|
||||
agentFiles: AgentFileSnapshot[],
|
||||
): Promise<void> {
|
||||
const failures: string[] = [];
|
||||
const restore = async (label: string, operation: () => Promise<void>) => {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
failures.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
};
|
||||
await restore('project config', () => atomicWriteText(projectConfigPath, projectSource));
|
||||
if (conversationsSource === null) {
|
||||
await restore('Conversation metadata', () => rm(conversationsPath, { force: true }));
|
||||
} else {
|
||||
await restore('Conversation metadata', () => atomicWriteText(conversationsPath, conversationsSource));
|
||||
}
|
||||
for (const agent of agentFiles) {
|
||||
await restore(`Agent ${agent.fileName}`, () => atomicWriteText(agent.filePath, agent.content));
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Pi cutover rollback was incomplete: ${failures.join('; ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrateCodingProjectToV2(
|
||||
projectPath: string,
|
||||
dependencies: CodingProjectMigrationDependencies,
|
||||
): Promise<CodingProjectMigrationResult> {
|
||||
const projectConfigPath = path.join(projectPath, CODING_PROJECT_CONFIG_PATH);
|
||||
const conversationsPath = path.join(projectPath, CODING_CONVERSATIONS_PATH);
|
||||
const projectSource = await readFile(projectConfigPath, 'utf8');
|
||||
const rawProject = JSON.parse(projectSource) as unknown;
|
||||
if (rawProject && typeof rawProject === 'object' && !Array.isArray(rawProject)
|
||||
&& (rawProject as { schemaVersion?: unknown }).schemaVersion === 2) {
|
||||
return {
|
||||
status: 'already-current',
|
||||
config: normalizeCodingProjectConfigV2(rawProject),
|
||||
};
|
||||
}
|
||||
const normalizedLegacyConfig = normalizeProjectConfig(rawProject);
|
||||
const legacyConfig = preserveLegacyAgentFields(rawProject, normalizedLegacyConfig);
|
||||
const conversationsSource = await readOptionalText(conversationsPath);
|
||||
const migrationTimestamp = dependencies.now?.() ?? new Date().toISOString();
|
||||
const backupDirectory = path.join(
|
||||
projectPath,
|
||||
'.niancode',
|
||||
'migration-backups',
|
||||
`opencode-cutover-${timestampKey(migrationTimestamp)}`,
|
||||
);
|
||||
const copy = dependencies.copyFile ?? copyFile;
|
||||
const writeJson = dependencies.writeJson ?? atomicWriteJson;
|
||||
|
||||
await mkdir(path.dirname(backupDirectory), { recursive: true });
|
||||
await mkdir(backupDirectory, { recursive: false });
|
||||
await copy(projectConfigPath, path.join(backupDirectory, 'project.json'));
|
||||
if (conversationsSource !== null) {
|
||||
await copy(conversationsPath, path.join(backupDirectory, 'conversations.json'));
|
||||
}
|
||||
const agentFiles = await snapshotLegacyAgentFiles(
|
||||
projectPath,
|
||||
normalizedLegacyConfig,
|
||||
backupDirectory,
|
||||
copy,
|
||||
);
|
||||
|
||||
const agents = await Promise.all(legacyConfig.agents.map((agent) => migrateAgent(
|
||||
legacyConfig,
|
||||
agent,
|
||||
dependencies.resolveLegacyModel,
|
||||
migrationTimestamp,
|
||||
)));
|
||||
const migratedConfig = normalizeCodingProjectConfigV2({
|
||||
schemaVersion: 2,
|
||||
projectType: legacyConfig.projectType,
|
||||
initialized: legacyConfig.initialized,
|
||||
agents,
|
||||
knowledgeDirectory: 'knowledge',
|
||||
legacyConversationNotice: 'pending',
|
||||
createdAt: legacyConfig.createdAt,
|
||||
updatedAt: migrationTimestamp,
|
||||
});
|
||||
|
||||
try {
|
||||
await writeJson(conversationsPath, createEmptyConversationFileV2());
|
||||
await writeJson(projectConfigPath, migratedConfig);
|
||||
for (const agent of agentFiles) await unlink(agent.filePath);
|
||||
} catch (error) {
|
||||
try {
|
||||
await restoreOriginalState(
|
||||
projectConfigPath,
|
||||
projectSource,
|
||||
conversationsPath,
|
||||
conversationsSource,
|
||||
agentFiles,
|
||||
);
|
||||
} catch (rollbackError) {
|
||||
const rollbackMessage = rollbackError instanceof Error
|
||||
? rollbackError.message
|
||||
: String(rollbackError);
|
||||
const migrationMessage = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Pi project migration failed (${migrationMessage}) and rollback was incomplete: ${rollbackMessage}`,
|
||||
{ cause: rollbackError },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'migrated',
|
||||
config: migratedConfig,
|
||||
backupDirectory,
|
||||
removedGeneratedAgents: agentFiles.filter((agent) => agent.generated).map((agent) => agent.fileName),
|
||||
backedUpUncertainAgents: agentFiles.filter((agent) => !agent.generated).map((agent) => agent.fileName),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user