Files
makelore/electron/coding-projects/migration.ts

319 lines
10 KiB
TypeScript

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),
};
}