Back up unknown legacy Agents, reconnect settled Conversation observation, remove remaining active repository residue, and restore the bundled Python verifier import.
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import {
|
|
copyFile,
|
|
mkdir,
|
|
readdir,
|
|
readFile,
|
|
rm,
|
|
unlink,
|
|
} from 'node:fs/promises';
|
|
import type { Dirent } from 'node:fs';
|
|
import path from 'node:path';
|
|
import type { ProductModelRef } from '../coding-runtime/contracts';
|
|
import {
|
|
buildLegacyProjectAgentManifest,
|
|
normalizeLegacyProjectConfigV1,
|
|
} from './legacy-v1';
|
|
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');
|
|
const expected = new Map(buildLegacyProjectAgentManifest(config).map((entry) => [
|
|
path.basename(entry.relativePath),
|
|
entry.content,
|
|
]));
|
|
let entries: Dirent[];
|
|
try {
|
|
entries = await readdir(agentDirectory, { withFileTypes: true });
|
|
} catch (error) {
|
|
if (isMissing(error)) return [];
|
|
throw error;
|
|
}
|
|
const snapshots: AgentFileSnapshot[] = [];
|
|
for (const entry of entries
|
|
.filter((candidate) => candidate.isFile())
|
|
.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
const fileName = entry.name;
|
|
const filePath = path.join(agentDirectory, fileName);
|
|
const content = await readOptionalText(filePath);
|
|
if (content === null) continue;
|
|
const generated = expected.get(fileName) === content;
|
|
if (generated) {
|
|
snapshots.push({ fileName, filePath, content, generated });
|
|
continue;
|
|
}
|
|
const backupPath = path.join(backupDirectory, '.opencode', 'agent', fileName);
|
|
await mkdir(path.dirname(backupPath), { recursive: true });
|
|
await copy(filePath, backupPath);
|
|
snapshots.push({ fileName, 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 = normalizeLegacyProjectConfigV1(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',
|
|
`pi-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),
|
|
};
|
|
}
|