feat: add coding project schema v2 migration

This commit is contained in:
2026-08-22 20:56:33 +08:00
parent 9a31dacb2a
commit 81d8ad1b6b
8 changed files with 1690 additions and 0 deletions

View File

@@ -0,0 +1,83 @@
# Task: Implement PI-020 project and conversation schema v2
## Identity
- Task ID: 20260822-pi-schema-v2-5e4b7a91
- Mode: Feature
- Branch: codex/20260822-pi-schema-v2-5e4b7a91-pi-schema-v2
- Worktree: D:\Datas\OthersProjects\makelore-pi-schema-v2-5e4b7a91
- Base commit: 9a31dacb2aad92a2deee0cec11fdb162a2916bfc
- Owner: codex
- Status: Completed
## Scope
- Implement `PI-020 — Project and Conversation schema v2 migration` from the merged Pi hard-cutover Spec/ticket graph.
- Add vendor-neutral project configuration, project registry, Conversation metadata store, atomic JSON persistence, and one-time OpenCode cutover migration under `electron/coding-projects`.
- Preserve stable Agent metadata while mapping legacy string models only through an injected unique resolver; unresolved/no-account models remain explicit user-selection requirements.
- Back up schema v1 metadata before replacement, initialize empty schema v2 Conversations, expose a persistent one-time notice state, and clean only proven generated `.opencode/agent` files while moving uncertain content to the migration backup.
- Add focused table, rollback, path-validation, single-flight, local-only creation, and performance tests without wiring the new stores into Renderer, Host routes, Provider Service, or Pi workers yet.
## Intent And Constraints
- Keep project/Agent/Conversation identity product-owned. Conversation IDs are Makelore UUIDs; Pi session IDs and opaque session keys remain optional Main-private binding fields.
- Reuse the vendor-neutral `ProductModelRef` and thinking-level contract from PI-010. Do not duplicate Pi/OpenCode provider identifiers in persisted schema v2.
- A legacy model string maps automatically only when the injected resolver returns one unique account/model reference. Missing, ambiguous, or unavailable account mappings persist `{ model: null, modelResolution: "required" }`; no default model fallback is allowed.
- Treat migration as one explicit v1→v2 branch, not a generic framework: create a timestamped backup first, use atomic per-file replacement, and restore original metadata/Agent files if any later migration operation fails.
- Never recursively delete `userData/opencode` or arbitrary project `.opencode` content. Only exact current generated Agent materializations may be deleted; modified or unknown Agent files move to backup, and other `.opencode` paths remain untouched and unread by new code.
- Project, partner, and empty Conversation creation are local metadata operations and must not import or call child-process/runtime code. Per-Conversation Pi binding creation exposes a single-flight contract but does not start Pi in this ticket.
- Preserve the explicit real-Provider Accepted Risk and deferred macOS decisions; neither affects this local metadata ticket or becomes a Pass here.
## Project Context Loaded
- `maintain-project-docs` created and verified isolated feature ownership at base `9a31dacb2aad92a2deee0cec11fdb162a2916bfc`; Concurrent Task Gate and Planning Gate passed.
- The planner audited PI-030 as Done in commit `fc7b80793c0a8b0a39d8eef31b57ee42895dd9fd`, recomputed the unchanged 18-node/31-edge DAG, and identified PI-020 as the unique Ready Frontier ticket from implementation base `9a31dac`.
- The planner status commit remains read-only on its own branch because the repository Integration Gate is currently held by blocked task `20260821-integrate-model-switch-fix-8f2d6c41`; this does not block isolated PI-020 feature work.
- Repository guidance, shared project memory, PI Spec sections 5/6/15/17/19/20, the PI-020 ticket, PI-010 contracts, existing v1 project/conversation modules, tests, and all planning peer task records were inspected before planning.
- Existing v1 ownership is split between `shared/project-config.ts`, `shared/project-conversations.ts`, and `electron/opencode/*`; those remain the old product path until later cutover tickets. PI-020 introduces the new `electron/coding-projects` boundary rather than incidentally rewriting current OpenCode routes.
- Other planning peers do not declare writes to the PI-020 files. `20260820-partner-create-model-parity-c83d` has an undefined scope and is retained as an unknown coordination warning; no confirmed contradictory decision or file ownership was found.
- `codebase-design` keeps persistence/migration details behind small store interfaces. The repository prohibition on subagents is honored; no parallel agent was created.
## Plan
1. Implement schema v2 types/normalizers and atomic JSON persistence, reusing PI-010 product model contracts without runtime imports beyond types.
2. Implement local project registry/config/Agent creation and Conversation CRUD with UUID identity, opaque session-key validation, serialized writes, and per-Conversation binding single-flight.
3. Implement the explicit v1 cutover: complete backup, injected unique model resolution, v2 atomic writes, one-time notice, generated/modified Agent classification, and rollback on failure.
4. Add migration-table, atomic-failure/retry, Agent-file, path, single-flight, no-spawn, and local performance tests.
5. Run focused tests, typecheck, lint, full tests, build when warranted, documentation gates, commit, and planner handoff.
## Outcome
- Added the vendor-neutral `electron/coding-projects` boundary with atomic JSON replacement, strict project schema v2, product-owned project registry, and Conversation schema v2. Agent and Conversation model state uses the PI-010 `ProductModelRef`; unresolved/no-account state is persisted as `model: null` plus `modelResolution: "required"` with no default fallback.
- Added local-only project, Agent, and unbound Conversation creation. Project path upserts and Conversation writes are serialized; concurrent first prompts share one per-Conversation binding flight and persist one validated Pi session ID/opaque session key pair.
- Added explicit v1→v2 migration with timestamped `.niancode/migration-backups/opencode-cutover-*` backup, injected unique legacy-model resolution, empty v2 Conversation initialization, and persistent `pending``acknowledged` legacy notice state.
- Migration preserves stable Agent ID/name/prompt/skills/archive. Exact cleanup proof uses the old runtime's normalized `buildProjectAgentManifest` output; locally modified or unknown `.opencode/agent` files are copied into the migration backup before being removed, while unrelated `.opencode` content remains untouched.
- A missing backup aborts before either v2 write. A failure after writes begin restores project metadata, Conversation metadata, and Agent file contents so the original v1 project remains retryable; a retry matrix test completes successfully after a deliberately corrupt Conversation write.
- The real Provider gate remains explicitly waived: real protocol/account compatibility, Provider concurrency, credential isolation, and Provider-specific behavior are Accepted Risk and remain unverified, not Pass. No real Provider or macOS validation was performed in this ticket.
## Verification
- `pnpm install --frozen-lockfile`: passed with repository-pinned pnpm 10.33.4.
- Focused schema/migration suite: 2 files, 15 tests passed. Coverage includes unique/unresolved/no-account mapping, stable Agent fields, modified/unknown generated files, no-backup refusal, corrupt write rollback/retry, empty v2 Conversations, one-time notice consumption, traversal/absolute session-key rejection, per-Conversation single-flight, atomic replacement/temp cleanup, local metadata performance budgets, and spawn spy = 0.
- `pnpm run typecheck`: passed.
- Scoped ESLint over `electron/coding-projects` and both focused tests: passed with no findings.
- `pnpm run lint:check`: passed with 0 errors and 6 pre-existing warnings outside this task's files.
- `pnpm test`: 184 files, 2133 tests passed.
- `pnpm run build:vite`: passed for Renderer, Main, Preload, and release utility output; existing dynamic-import and chunk-size warnings remain unchanged.
- `git diff --check`: passed.
## Follow-ups
- `PI-040` may consume project/Agent `ProductModelRef` data after integration; it owns Provider catalog, credential projection, and managed prompt/skill resources. The accepted real-Provider risks must remain labeled unverified rather than being inferred from these local metadata tests.
- `PI-050` may connect `ensureSessionBinding` to the Pi worker pool/session registry. Its runtime integration must retain the store's single-flight semantics and keep the opaque key relative/private.
- `PI-100` may expose project/Agent/Conversation create routes. These routes must stay local-only until a prompt explicitly requests runtime preparation.
- `PI-140` owns final cutover wiring and old OpenCode removal. It should consume the one-time legacy notice and must not broaden cleanup beyond the conservative migration behavior delivered here.
## Promotion Candidates
- Target: Pi cutover migration implementation guidance in the architecture/planner task.
Proposal: preserve v1 Agent prompt and selected skill IDs from raw project metadata, but classify deletable generated Agent files only against the old runtime's normalized manifest; any mismatch remains uncertain and is moved into the migration backup.
Evidence: the focused migration matrix preserves a retired legacy skill ID and prompt whitespace while deleting two exact generated files, backing up one modified generated file plus one unknown file, and leaving unrelated `.opencode/skills` content intact.
Future impact: `PI-140` can remove old runtime code without reimplementing or widening the data-cleanup boundary.
Semantic conflicts: none; this is the conservative implementation of PI-020 Required work 6 and Acceptance data-preservation rules.
Human confirmation required: no.

View File

@@ -0,0 +1,27 @@
import { randomUUID } from 'node:crypto';
import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
export type JsonFileWriter = (filePath: string, value: unknown) => Promise<void>;
export async function atomicWriteText(filePath: string, source: string): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
const temporaryPath = path.join(
path.dirname(filePath),
`.${path.basename(filePath)}.${randomUUID()}.tmp`,
);
try {
await writeFile(temporaryPath, source, { encoding: 'utf8', flag: 'wx' });
await rename(temporaryPath, filePath);
} finally {
await rm(temporaryPath, { force: true }).catch(() => undefined);
}
}
export async function atomicWriteJson(filePath: string, value: unknown): Promise<void> {
await atomicWriteText(filePath, `${JSON.stringify(value, null, 2)}\n`);
}
export async function readJsonFile(filePath: string): Promise<unknown> {
return JSON.parse(await readFile(filePath, 'utf8')) as unknown;
}

View File

@@ -0,0 +1,257 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { ConversationModelState, ProductModelRef } from '../coding-runtime/contracts';
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
import { normalizeProductModelRef } from './project-config';
export const CODING_CONVERSATIONS_PATH = '.niancode/conversations.json';
export interface CodingConversationV2 extends ConversationModelState {
id: string;
agentId: string;
title: string;
piSessionId?: string;
sessionKey?: string;
archivedAt: string | null;
unread: boolean;
createdAt: string;
updatedAt: string;
}
export interface CodingConversationFileV2 {
schemaVersion: 2;
conversations: CodingConversationV2[];
}
export interface CreateCodingConversationInput {
agentId: string;
title: string;
model: ProductModelRef | null;
modelResolution: 'resolved' | 'required';
}
export interface PiSessionBinding {
piSessionId: string;
sessionKey: string;
}
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const SESSION_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
function conversationFilePath(projectPath: string): string {
return path.join(projectPath, CODING_CONVERSATIONS_PATH);
}
function cleanString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function normalizeModelState(value: {
model?: unknown;
modelResolution?: unknown;
}): ConversationModelState {
if (value.modelResolution === 'required') {
if (value.model !== null) throw new Error('Required Conversation model must be null');
return { model: null, modelResolution: 'required' };
}
if (value.modelResolution !== 'resolved') throw new Error('Conversation model resolution is invalid');
return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' };
}
export function validateSessionKey(value: unknown): string {
const sessionKey = typeof value === 'string' ? value : '';
if (!SESSION_KEY_PATTERN.test(sessionKey) || path.isAbsolute(sessionKey)) {
throw new Error('Pi session key must be an opaque relative key');
}
return sessionKey;
}
function normalizeConversation(value: unknown): CodingConversationV2 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Coding Conversation must be an object');
}
const record = value as Partial<CodingConversationV2>;
const id = cleanString(record.id);
const agentId = cleanString(record.agentId);
const title = cleanString(record.title);
const createdAt = cleanString(record.createdAt);
const updatedAt = cleanString(record.updatedAt);
if (!UUID_PATTERN.test(id)) throw new Error('Conversation id must be a UUID');
if (!agentId) throw new Error('Conversation Agent id is required');
if (!title) throw new Error('Conversation title is required');
if (!createdAt || !updatedAt) throw new Error('Conversation timestamps are required');
const sessionKey = record.sessionKey === undefined ? undefined : validateSessionKey(record.sessionKey);
const piSessionId = cleanString(record.piSessionId) || undefined;
if ((sessionKey === undefined) !== (piSessionId === undefined)) {
throw new Error('Pi session id and session key must be persisted together');
}
return {
id,
agentId,
title,
...normalizeModelState(record),
...(piSessionId ? { piSessionId } : {}),
...(sessionKey ? { sessionKey } : {}),
archivedAt: cleanString(record.archivedAt) || null,
unread: record.unread === true,
createdAt,
updatedAt,
};
}
export function createEmptyConversationFileV2(): CodingConversationFileV2 {
return { schemaVersion: 2, conversations: [] };
}
export function normalizeCodingConversationFileV2(value: unknown): CodingConversationFileV2 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Coding Conversation file must be an object');
}
const record = value as Partial<CodingConversationFileV2>;
if (record.schemaVersion !== 2) throw new Error('Unsupported coding Conversation schema');
if (!Array.isArray(record.conversations)) throw new Error('Conversations must be an array');
const conversations = record.conversations.map(normalizeConversation);
if (new Set(conversations.map((conversation) => conversation.id)).size !== conversations.length) {
throw new Error('Duplicate Conversation id');
}
return { schemaVersion: 2, conversations };
}
export function createCodingConversationStore(
projectPath: string,
options: {
createId?: () => string;
now?: () => string;
writer?: JsonFileWriter;
} = {},
) {
const createId = options.createId ?? randomUUID;
const now = options.now ?? (() => new Date().toISOString());
const writer = options.writer ?? atomicWriteJson;
const bindingFlights = new Map<string, Promise<CodingConversationV2>>();
let mutationTail = Promise.resolve();
async function readNow(): Promise<CodingConversationFileV2> {
try {
return normalizeCodingConversationFileV2(await readJsonFile(conversationFilePath(projectPath)));
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyConversationFileV2();
throw error;
}
}
function mutate<T>(operation: (file: CodingConversationFileV2) => Promise<{
result: T;
file: CodingConversationFileV2;
}>): Promise<T> {
const execute = async () => {
const change = await operation(await readNow());
const normalized = normalizeCodingConversationFileV2(change.file);
await writer(conversationFilePath(projectPath), normalized);
return change.result;
};
const result = mutationTail.then(execute, execute);
mutationTail = result.then(() => undefined, () => undefined);
return result;
}
async function getConversation(conversationId: string): Promise<CodingConversationV2 | null> {
await mutationTail;
return (await readNow()).conversations.find((item) => item.id === conversationId) ?? null;
}
return {
async read(): Promise<CodingConversationFileV2> {
await mutationTail;
return await readNow();
},
async create(input: CreateCodingConversationInput): Promise<CodingConversationV2> {
return await mutate(async (file) => {
const timestamp = now();
const conversation = normalizeConversation({
id: createId(),
agentId: input.agentId,
title: input.title,
model: input.model,
modelResolution: input.modelResolution,
archivedAt: null,
unread: false,
createdAt: timestamp,
updatedAt: timestamp,
});
return {
result: conversation,
file: { schemaVersion: 2, conversations: [conversation, ...file.conversations] },
};
});
},
get: getConversation,
async patchMetadata(
conversationId: string,
patch: Partial<Pick<CodingConversationV2, 'title' | 'archivedAt' | 'unread'>>,
): Promise<CodingConversationV2> {
return await mutate(async (file) => {
const current = file.conversations.find((item) => item.id === conversationId);
if (!current) throw new Error('Conversation does not exist');
const updated = normalizeConversation({
...current,
...(patch.title !== undefined ? { title: patch.title } : {}),
...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}),
...(patch.unread !== undefined ? { unread: patch.unread } : {}),
updatedAt: now(),
});
return {
result: updated,
file: {
schemaVersion: 2,
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
},
};
});
},
async ensureSessionBinding(
conversationId: string,
createBinding: () => Promise<PiSessionBinding>,
): Promise<CodingConversationV2> {
const inFlight = bindingFlights.get(conversationId);
if (inFlight) return await inFlight;
const flight = (async () => {
const current = await getConversation(conversationId);
if (!current) throw new Error('Conversation does not exist');
if (current.sessionKey && current.piSessionId) return current;
const binding = await createBinding();
const sessionKey = validateSessionKey(binding.sessionKey);
const piSessionId = cleanString(binding.piSessionId);
if (!piSessionId) throw new Error('Pi session id is required');
return await mutate(async (file) => {
const latest = file.conversations.find((item) => item.id === conversationId);
if (!latest) throw new Error('Conversation does not exist');
if (latest.sessionKey && latest.piSessionId) return { result: latest, file };
const updated = normalizeConversation({
...latest,
sessionKey,
piSessionId,
updatedAt: now(),
});
return {
result: updated,
file: {
schemaVersion: 2,
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
},
};
});
})();
bindingFlights.set(conversationId, flight);
try {
return await flight;
} finally {
bindingFlights.delete(conversationId);
}
},
};
}

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

View File

@@ -0,0 +1,334 @@
import { mkdir, stat } from 'node:fs/promises';
import path from 'node:path';
import {
isProjectAgentAvatarDataUrl,
isProjectType,
type ProjectAgentResponsibility,
type ProjectType,
} from '../../shared/project-config';
import type {
ConversationModelState,
ConversationThinkingLevel,
ProductModelRef,
} from '../coding-runtime/contracts';
import { atomicWriteJson, readJsonFile, type JsonFileWriter } from './atomic-json';
export const CODING_PROJECT_CONFIG_PATH = '.niancode/project.json';
export const LEGACY_CONVERSATION_NOTICE_VALUES = ['none', 'pending', 'acknowledged'] as const;
export type LegacyConversationNotice = (typeof LEGACY_CONVERSATION_NOTICE_VALUES)[number];
export interface CodingProjectAgentV2 extends ConversationModelState {
id: string;
avatarId: string;
avatarDataUrl?: string;
roleName: string;
name: string;
builtIn: boolean;
enabled: boolean;
skillIds: string[];
responsibility: ProjectAgentResponsibility;
prompt: string;
archivedAt: string | null;
pinned: boolean;
createdAt: string;
updatedAt: string;
}
export interface CodingProjectConfigV2 {
schemaVersion: 2;
projectType: ProjectType;
initialized: boolean;
agents: CodingProjectAgentV2[];
knowledgeDirectory: 'knowledge';
legacyConversationNotice: LegacyConversationNotice;
createdAt: string;
updatedAt: string;
}
export type CodingProjectConfigReadResult =
| { status: 'valid'; config: CodingProjectConfigV2 }
| { status: 'missing' }
| { status: 'invalid'; error: string };
export interface CreateCodingProjectAgentInput {
id: string;
avatarId: string;
avatarDataUrl?: string;
roleName: string;
name: string;
model: ProductModelRef | null;
modelResolution: 'resolved' | 'required';
skillIds?: string[];
responsibility: ProjectAgentResponsibility;
prompt?: string;
pinned?: boolean;
}
const AGENT_ID_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
const AVATAR_ID_PATTERN = /^avatar-(0[1-9]|1[0-6])$/;
const THINKING_LEVELS = new Set<ConversationThinkingLevel>([
'off',
'minimal',
'low',
'medium',
'high',
]);
function projectConfigPath(projectPath: string): string {
return path.join(projectPath, CODING_PROJECT_CONFIG_PATH);
}
function cleanString(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cleanStringList(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return [...new Set(value.map(cleanString).filter(Boolean))];
}
function normalizeResponsibility(value: unknown): ProjectAgentResponsibility {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Project Agent responsibility is invalid');
}
const record = value as Partial<ProjectAgentResponsibility>;
if (typeof record.mission !== 'string'
|| !Array.isArray(record.owns)
|| !Array.isArray(record.boundaries)
|| !Array.isArray(record.collaborators)
|| !Array.isArray(record.principles)) {
throw new Error('Project Agent responsibility is invalid');
}
return {
mission: cleanString(record.mission),
owns: cleanStringList(record.owns),
boundaries: cleanStringList(record.boundaries),
collaborators: cleanStringList(record.collaborators),
principles: cleanStringList(record.principles),
};
}
export function normalizeProductModelRef(value: unknown): ProductModelRef {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Product model must be an object');
}
const record = value as Partial<ProductModelRef>;
const accountId = cleanString(record.accountId);
const modelId = cleanString(record.modelId);
if (!accountId || !modelId || !THINKING_LEVELS.has(record.thinkingLevel as ConversationThinkingLevel)) {
throw new Error('Product model reference is invalid');
}
return {
accountId,
modelId,
thinkingLevel: record.thinkingLevel as ConversationThinkingLevel,
};
}
function normalizeModelState(value: {
model?: unknown;
modelResolution?: unknown;
}): ConversationModelState {
if (value.modelResolution === 'required') {
if (value.model !== null) throw new Error('Required model selection must not contain a model');
return { model: null, modelResolution: 'required' };
}
if (value.modelResolution !== 'resolved') throw new Error('Project Agent model resolution is invalid');
return { model: normalizeProductModelRef(value.model), modelResolution: 'resolved' };
}
function normalizeAgent(value: unknown): CodingProjectAgentV2 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Project Agent must be an object');
}
const record = value as Partial<CodingProjectAgentV2>;
const id = cleanString(record.id);
const name = cleanString(record.name);
const avatarId = cleanString(record.avatarId);
const roleName = cleanString(record.roleName);
const createdAt = cleanString(record.createdAt);
const updatedAt = cleanString(record.updatedAt);
if (!AGENT_ID_PATTERN.test(id)) throw new Error('Project Agent id is invalid');
if (!name) throw new Error('Project Agent name is required');
if (!AVATAR_ID_PATTERN.test(avatarId)) throw new Error('Project Agent avatar is invalid');
if (!roleName) throw new Error('Project Agent role is required');
if (!createdAt || !updatedAt) throw new Error('Project Agent timestamps are required');
if (typeof record.builtIn !== 'boolean'
|| typeof record.enabled !== 'boolean'
|| typeof record.pinned !== 'boolean'
|| !Array.isArray(record.skillIds)
|| typeof record.prompt !== 'string'
|| !(record.archivedAt === null || typeof record.archivedAt === 'string')) {
throw new Error('Project Agent metadata is invalid');
}
return {
id,
avatarId,
...(isProjectAgentAvatarDataUrl(record.avatarDataUrl)
? { avatarDataUrl: record.avatarDataUrl }
: {}),
roleName,
name,
builtIn: record.builtIn === true,
enabled: record.enabled !== false,
...normalizeModelState(record),
skillIds: cleanStringList(record.skillIds),
responsibility: normalizeResponsibility(record.responsibility),
prompt: record.prompt,
archivedAt: cleanString(record.archivedAt) || null,
pinned: record.pinned === true,
createdAt,
updatedAt,
};
}
function validateAgentNames(agents: CodingProjectAgentV2[]): void {
const names = new Set<string>();
for (const agent of agents) {
if (agent.name.length > 30) throw new Error('Project Agent name is too long');
const key = agent.name.toLocaleLowerCase();
if (names.has(key)) throw new Error('Duplicate project Agent name');
names.add(key);
}
}
export function normalizeCodingProjectConfigV2(value: unknown): CodingProjectConfigV2 {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error('Coding project config must be an object');
}
const record = value as Partial<CodingProjectConfigV2>;
if (record.schemaVersion !== 2) throw new Error('Unsupported coding project config schema');
if (!isProjectType(record.projectType)) throw new Error('Invalid project type');
if (typeof record.initialized !== 'boolean') throw new Error('Project initialized state is invalid');
if (record.knowledgeDirectory !== 'knowledge') throw new Error('Project knowledge directory is invalid');
if (!Array.isArray(record.agents)) throw new Error('Project Agents must be an array');
const createdAt = cleanString(record.createdAt);
const updatedAt = cleanString(record.updatedAt);
if (!createdAt || !updatedAt) throw new Error('Project timestamps are required');
if (!LEGACY_CONVERSATION_NOTICE_VALUES.includes(record.legacyConversationNotice as LegacyConversationNotice)) {
throw new Error('Legacy Conversation notice state is invalid');
}
const agents = record.agents.map(normalizeAgent);
if (new Set(agents.map((agent) => agent.id)).size !== agents.length) {
throw new Error('Duplicate project Agent id');
}
validateAgentNames(agents);
return {
schemaVersion: 2,
projectType: record.projectType,
initialized: record.initialized === true,
agents,
knowledgeDirectory: 'knowledge',
legacyConversationNotice: record.legacyConversationNotice as LegacyConversationNotice,
createdAt,
updatedAt,
};
}
export function createCodingProjectConfigV2(
now = new Date().toISOString(),
projectType: ProjectType = 'custom',
): CodingProjectConfigV2 {
return {
schemaVersion: 2,
projectType,
initialized: false,
agents: [],
knowledgeDirectory: 'knowledge',
legacyConversationNotice: 'none',
createdAt: now,
updatedAt: now,
};
}
export async function readCodingProjectConfigV2(
projectPath: string,
): Promise<CodingProjectConfigReadResult> {
try {
return {
status: 'valid',
config: normalizeCodingProjectConfigV2(await readJsonFile(projectConfigPath(projectPath))),
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' };
return { status: 'invalid', error: error instanceof Error ? error.message : String(error) };
}
}
export async function writeCodingProjectConfigV2(
projectPath: string,
value: unknown,
writer: JsonFileWriter = atomicWriteJson,
): Promise<CodingProjectConfigV2> {
const config = normalizeCodingProjectConfigV2(value);
await writer(projectConfigPath(projectPath), config);
return config;
}
export async function createCodingProjectMetadata(
projectPath: string,
options: {
projectType?: ProjectType;
now?: string;
writer?: JsonFileWriter;
} = {},
): Promise<CodingProjectConfigV2> {
try {
await stat(projectConfigPath(projectPath));
throw new Error('Coding project configuration already exists');
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
const config = createCodingProjectConfigV2(options.now, options.projectType);
await Promise.all([
mkdir(path.join(projectPath, '.niancode'), { recursive: true }),
mkdir(path.join(projectPath, 'knowledge'), { recursive: true }),
]);
await (options.writer ?? atomicWriteJson)(projectConfigPath(projectPath), config);
return config;
}
export async function createCodingProjectAgent(
projectPath: string,
input: CreateCodingProjectAgentInput,
options: { now?: string; writer?: JsonFileWriter } = {},
): Promise<CodingProjectAgentV2> {
const result = await readCodingProjectConfigV2(projectPath);
if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid');
if (result.config.agents.some((agent) => agent.id === input.id.trim())) {
throw new Error('Project Agent id already exists');
}
const now = options.now ?? new Date().toISOString();
const agent = normalizeAgent({
...input,
builtIn: false,
enabled: true,
skillIds: input.skillIds ?? [],
prompt: input.prompt ?? '',
archivedAt: null,
pinned: input.pinned ?? false,
createdAt: now,
updatedAt: now,
});
await writeCodingProjectConfigV2(projectPath, {
...result.config,
initialized: true,
agents: [...result.config.agents, agent],
updatedAt: now,
}, options.writer);
return agent;
}
export async function acknowledgeLegacyConversationNotice(
projectPath: string,
options: { now?: string; writer?: JsonFileWriter } = {},
): Promise<CodingProjectConfigV2> {
const result = await readCodingProjectConfigV2(projectPath);
if (result.status !== 'valid') throw new Error('Coding project configuration is missing or invalid');
if (result.config.legacyConversationNotice !== 'pending') return result.config;
return await writeCodingProjectConfigV2(projectPath, {
...result.config,
legacyConversationNotice: 'acknowledged',
updatedAt: options.now ?? new Date().toISOString(),
}, options.writer);
}

View File

@@ -0,0 +1,180 @@
import { randomUUID } from 'node:crypto';
import path from 'node:path';
import type { ProjectType } from '../../shared/project-config';
import {
createCodingProjectMetadata,
type CodingProjectConfigV2,
} from './project-config';
export interface CodingProject {
id: string;
path: string;
name: string;
createdAt: string;
updatedAt: string;
lastOpenedAt: string;
}
export interface CodingProjectStoreData {
projects: Record<string, CodingProject>;
activeProjectId: string | null;
}
export interface CodingProjectStorage {
read(): Promise<CodingProjectStoreData | undefined>;
write(data: CodingProjectStoreData): Promise<void>;
}
export interface CodingProjectKeyValueStore {
get(key: string): unknown;
set(key: string, value: CodingProjectStoreData): unknown;
}
export type CodingProjectStore = ReturnType<typeof createCodingProjectStore>;
const DEFAULT_PROJECT_STORE_KEY = 'coding-projects-v2';
export function normalizeCodingProjectPath(input: string): string {
const normalized = path.normalize(path.resolve(input.trim()));
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
export function createMemoryCodingProjectStorage(
initial?: CodingProjectStoreData,
): CodingProjectStorage {
let stored = initial ? structuredClone(initial) : undefined;
return {
async read() {
return stored ? structuredClone(stored) : undefined;
},
async write(data) {
stored = structuredClone(data);
},
};
}
export function createCodingProjectStorageFromStore(
store: CodingProjectKeyValueStore,
key = DEFAULT_PROJECT_STORE_KEY,
): CodingProjectStorage {
return {
async read() {
const data = store.get(key);
return data ? structuredClone(data) as CodingProjectStoreData : undefined;
},
async write(data) {
store.set(key, structuredClone(data));
},
};
}
function emptyStoreData(): CodingProjectStoreData {
return { projects: {}, activeProjectId: null };
}
export function createCodingProjectStore(
storage: CodingProjectStorage,
options: {
createId?: () => string;
now?: () => string;
} = {},
) {
const createId = options.createId ?? randomUUID;
const now = options.now ?? (() => new Date().toISOString());
let mutationTail = Promise.resolve();
async function readData(): Promise<CodingProjectStoreData> {
return (await storage.read()) ?? emptyStoreData();
}
function mutate<T>(operation: () => Promise<T>): Promise<T> {
const result = mutationTail.then(operation, operation);
mutationTail = result.then(() => undefined, () => undefined);
return result;
}
async function upsertProject(input: string, activate: boolean): Promise<CodingProject> {
return await mutate(async () => {
const data = await readData();
const normalizedPath = normalizeCodingProjectPath(input);
const existing = Object.values(data.projects)
.find((project) => project.path === normalizedPath);
const timestamp = now();
const project: CodingProject = existing
? {
...existing,
name: path.basename(normalizedPath),
updatedAt: timestamp,
lastOpenedAt: timestamp,
}
: {
id: createId(),
path: normalizedPath,
name: path.basename(normalizedPath),
createdAt: timestamp,
updatedAt: timestamp,
lastOpenedAt: timestamp,
};
data.projects[project.id] = project;
if (activate) data.activeProjectId = project.id;
await storage.write(data);
return project;
});
}
return {
async openFolder(input: string): Promise<CodingProject> {
return await upsertProject(input, true);
},
async rememberProject(input: string): Promise<CodingProject> {
return await upsertProject(input, false);
},
async listProjects(): Promise<CodingProject[]> {
await mutationTail;
return Object.values((await readData()).projects)
.sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt));
},
async getActiveProject(): Promise<CodingProject | null> {
await mutationTail;
const data = await readData();
return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null;
},
async setActiveProject(projectId: string | null): Promise<CodingProject | null> {
return await mutate(async () => {
const data = await readData();
data.activeProjectId = projectId && data.projects[projectId] ? projectId : null;
await storage.write(data);
return data.activeProjectId ? data.projects[data.activeProjectId] : null;
});
},
async removeProject(projectId: string): Promise<void> {
await mutate(async () => {
const data = await readData();
delete data.projects[projectId];
if (data.activeProjectId === projectId) data.activeProjectId = null;
await storage.write(data);
});
},
};
}
export async function createLocalCodingProject(
input: {
projectPath: string;
projectType?: ProjectType;
now?: string;
},
store: CodingProjectStore,
): Promise<{ project: CodingProject; config: CodingProjectConfigV2 }> {
const config = await createCodingProjectMetadata(input.projectPath, {
projectType: input.projectType,
now: input.now,
});
const project = await store.openFolder(input.projectPath);
return { project, config };
}

View File

@@ -0,0 +1,261 @@
// @vitest-environment node
import {
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { ProjectAgentConfig, ProjectConfig } from '../../shared/project-config';
import {
buildProjectAgentManifest,
normalizeProjectConfig,
} from '../../electron/opencode/project-config';
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
import {
acknowledgeLegacyConversationNotice,
readCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import { migrateCodingProjectToV2 } from '../../electron/coding-projects/migration';
const scratchRoots: string[] = [];
const CREATED = '2026-08-20T00:00:00.000Z';
const MIGRATED = '2026-08-22T09:00:00.000Z';
const RETRIED = '2026-08-22T09:01:00.000Z';
const MODEL = {
accountId: 'account-a',
modelId: 'provider/model-a',
thinkingLevel: 'high' as const,
};
function makeAgent(overrides: Partial<ProjectAgentConfig>): ProjectAgentConfig {
return {
id: 'agent-a',
avatarId: 'avatar-01',
roleName: 'Implementer',
name: 'Agent A',
builtIn: false,
enabled: true,
model: 'legacy/model-a',
skillIds: ['tdd', 'game-engine'],
responsibility: {
mission: 'Implement',
owns: ['electron/coding-projects'],
boundaries: [],
collaborators: [],
principles: ['Preserve state'],
},
prompt: '\nOriginal prompt\n',
archivedAt: null,
pinned: true,
createdAt: CREATED,
updatedAt: CREATED,
...overrides,
};
}
function makeLegacyConfig(): ProjectConfig {
return {
schemaVersion: 1,
projectType: 'custom',
initialized: true,
defaultModel: null,
agents: [
makeAgent({ id: 'unique', name: 'Unique', model: 'legacy/unique' }),
makeAgent({
id: 'unresolved',
avatarId: 'avatar-02',
name: 'Unresolved',
model: 'legacy/ambiguous',
prompt: 'Keep unresolved prompt',
archivedAt: '2026-08-21T00:00:00.000Z',
}),
makeAgent({
id: 'no-account',
avatarId: 'avatar-03',
name: 'No Account',
model: null,
skillIds: [],
}),
],
knowledgeDirectory: 'knowledge',
createdAt: CREATED,
updatedAt: CREATED,
};
}
async function stageLegacyProject(options: { conversations?: boolean } = {}): Promise<{
projectPath: string;
config: ProjectConfig;
projectSource: string;
conversationSource: string | null;
}> {
const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-migration-v2-'));
scratchRoots.push(projectPath);
const config = makeLegacyConfig();
const projectFile = path.join(projectPath, '.niancode', 'project.json');
const agentDirectory = path.join(projectPath, '.opencode', 'agent');
await Promise.all([
mkdir(path.dirname(projectFile), { recursive: true }),
mkdir(agentDirectory, { recursive: true }),
mkdir(path.join(projectPath, '.opencode', 'skills'), { recursive: true }),
]);
const projectSource = `${JSON.stringify(config, null, 2)}\n`;
await writeFile(projectFile, projectSource, 'utf8');
for (const entry of buildProjectAgentManifest(normalizeProjectConfig(config)).entries) {
await writeFile(path.join(projectPath, '.opencode', entry.relativePath), entry.content, 'utf8');
}
await writeFile(path.join(projectPath, '.opencode', 'skills', 'keep.md'), 'keep me', 'utf8');
let conversationSource: string | null = null;
if (options.conversations !== false) {
conversationSource = `${JSON.stringify({
schemaVersion: 1,
sessions: [{ id: 'old-opencode-session', agentId: 'unique', title: 'Legacy' }],
}, null, 2)}\n`;
await writeFile(path.join(projectPath, '.niancode', 'conversations.json'), conversationSource, 'utf8');
}
return { projectPath, config, projectSource, conversationSource };
}
afterEach(async () => {
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
})));
});
describe('coding project v1 to v2 migration', () => {
it('backs up v1, maps unique models, requires unresolved selection, and hides old sessions', async () => {
const staged = await stageLegacyProject();
const unresolvedFile = path.join(staged.projectPath, '.opencode', 'agent', 'unresolved.md');
const customFile = path.join(staged.projectPath, '.opencode', 'agent', 'custom.md');
await writeFile(unresolvedFile, 'locally modified Agent\n', 'utf8');
await writeFile(customFile, 'unknown Agent\n', 'utf8');
const resolveLegacyModel = vi.fn(async ({ legacyModel }: { legacyModel: string }) => (
legacyModel === 'legacy/unique' ? MODEL : null
));
const result = await migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel,
now: () => MIGRATED,
});
expect(result.status).toBe('migrated');
if (result.status !== 'migrated') throw new Error('Expected migrated result');
expect(result.config.agents).toEqual([
expect.objectContaining({
id: 'unique',
name: 'Unique',
prompt: '\nOriginal prompt\n',
skillIds: ['tdd', 'game-engine'],
archivedAt: null,
model: MODEL,
modelResolution: 'resolved',
}),
expect.objectContaining({
id: 'unresolved',
prompt: 'Keep unresolved prompt',
archivedAt: '2026-08-21T00:00:00.000Z',
model: null,
modelResolution: 'required',
}),
expect.objectContaining({
id: 'no-account',
model: null,
modelResolution: 'required',
}),
]);
expect(resolveLegacyModel).toHaveBeenCalledTimes(2);
expect(result.removedGeneratedAgents.sort()).toEqual(['no-account.md', 'unique.md']);
expect(result.backedUpUncertainAgents.sort()).toEqual(['custom.md', 'unresolved.md']);
expect(JSON.parse(await readFile(
path.join(staged.projectPath, '.niancode', 'conversations.json'),
'utf8',
))).toEqual({ schemaVersion: 2, conversations: [] });
expect(JSON.parse(await readFile(path.join(result.backupDirectory, 'project.json'), 'utf8')))
.toMatchObject({ schemaVersion: 1 });
expect(await readFile(path.join(result.backupDirectory, 'conversations.json'), 'utf8'))
.toBe(staged.conversationSource);
expect(await readFile(
path.join(result.backupDirectory, '.opencode', 'agent', 'unresolved.md'),
'utf8',
)).toBe('locally modified Agent\n');
expect(await readFile(
path.join(result.backupDirectory, '.opencode', 'agent', 'custom.md'),
'utf8',
)).toBe('unknown Agent\n');
await expect(readFile(unresolvedFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
await expect(readFile(customFile, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' });
expect(await readFile(path.join(staged.projectPath, '.opencode', 'skills', 'keep.md'), 'utf8'))
.toBe('keep me');
const pending = await readCodingProjectConfigV2(staged.projectPath);
expect(pending).toMatchObject({ status: 'valid', config: { legacyConversationNotice: 'pending' } });
const acknowledged = await acknowledgeLegacyConversationNotice(staged.projectPath, { now: RETRIED });
expect(acknowledged.legacyConversationNotice).toBe('acknowledged');
});
it('does not overwrite v1 when the backup cannot be created', async () => {
const staged = await stageLegacyProject();
const writer = vi.fn(atomicWriteJson);
const blockedCopy = vi.fn(async () => {
throw new Error('backup unavailable');
}) as unknown as typeof copyFile;
await expect(migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async () => MODEL,
now: () => MIGRATED,
copyFile: blockedCopy,
writeJson: writer,
})).rejects.toThrow('backup unavailable');
expect(writer).not.toHaveBeenCalled();
expect(await readFile(path.join(staged.projectPath, '.niancode', 'project.json'), 'utf8'))
.toBe(staged.projectSource);
expect(await readFile(path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8'))
.toBe(staged.conversationSource);
});
it('restores retryable v1 state after a corrupt metadata write and succeeds on retry', async () => {
const staged = await stageLegacyProject();
let writeCount = 0;
const corruptingWriter = vi.fn(async (filePath: string, value: unknown) => {
writeCount += 1;
if (writeCount === 2) {
await atomicWriteJson(filePath, { corrupt: true });
throw new Error('simulated project write failure');
}
await atomicWriteJson(filePath, value);
});
await expect(migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
now: () => MIGRATED,
writeJson: corruptingWriter,
})).rejects.toThrow('simulated project write failure');
expect(await readFile(path.join(staged.projectPath, '.niancode', 'project.json'), 'utf8'))
.toBe(staged.projectSource);
expect(await readFile(path.join(staged.projectPath, '.niancode', 'conversations.json'), 'utf8'))
.toBe(staged.conversationSource);
for (const entry of buildProjectAgentManifest(normalizeProjectConfig(staged.config)).entries) {
expect(await readFile(path.join(staged.projectPath, '.opencode', entry.relativePath), 'utf8'))
.toBe(entry.content);
}
const retry = await migrateCodingProjectToV2(staged.projectPath, {
resolveLegacyModel: async ({ legacyModel }) => legacyModel === 'legacy/unique' ? MODEL : null,
now: () => RETRIED,
});
expect(retry.status).toBe('migrated');
expect(JSON.parse(await readFile(
path.join(staged.projectPath, '.niancode', 'conversations.json'),
'utf8',
))).toEqual({ schemaVersion: 2, conversations: [] });
});
});

View File

@@ -0,0 +1,230 @@
// @vitest-environment node
import { spawn } from 'node:child_process';
import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
createCodingConversationStore,
validateSessionKey,
} from '../../electron/coding-projects/conversation-store';
import { atomicWriteJson } from '../../electron/coding-projects/atomic-json';
import {
createCodingProjectAgent,
readCodingProjectConfigV2,
} from '../../electron/coding-projects/project-config';
import {
createCodingProjectStore,
createLocalCodingProject,
createMemoryCodingProjectStorage,
} from '../../electron/coding-projects/project-store';
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
const scratchRoots: string[] = [];
const NOW = '2026-08-22T08:00:00.000Z';
const NEXT = '2026-08-22T08:00:01.000Z';
const MODEL = {
accountId: 'account-local',
modelId: 'provider/model-a',
thinkingLevel: 'medium' as const,
};
const RESPONSIBILITY = {
mission: 'Implement the assigned work',
owns: ['electron/coding-projects'],
boundaries: [],
collaborators: [],
principles: ['Keep metadata local'],
};
async function makeProjectPath(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'makelore-coding-v2-'));
scratchRoots.push(root);
return root;
}
afterEach(async () => {
vi.clearAllMocks();
await Promise.all(scratchRoots.splice(0).map((root) => rm(root, {
recursive: true,
force: true,
})));
});
describe('coding project schema v2', () => {
it('creates project, Agent, and empty Conversation metadata without a runtime child', async () => {
const projectPath = await makeProjectPath();
const storage = createMemoryCodingProjectStorage();
const store = createCodingProjectStore(storage, {
createId: () => 'project-stable-id',
now: () => NOW,
});
const projectStartedAt = performance.now();
const { project, config } = await createLocalCodingProject({ projectPath, now: NOW }, store);
const projectDurationMs = performance.now() - projectStartedAt;
const agentStartedAt = performance.now();
const agent = await createCodingProjectAgent(projectPath, {
id: 'implementer',
avatarId: 'avatar-01',
roleName: 'Implementer',
name: 'Implementation Agent',
model: MODEL,
modelResolution: 'resolved',
responsibility: RESPONSIBILITY,
prompt: '\nPreserve this prompt.\n',
skillIds: ['tdd'],
}, { now: NEXT });
const agentDurationMs = performance.now() - agentStartedAt;
const conversations = createCodingConversationStore(projectPath, {
createId: () => 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
now: () => NEXT,
});
const conversationStartedAt = performance.now();
const conversation = await conversations.create({
agentId: agent.id,
title: 'Local draft',
model: MODEL,
modelResolution: 'resolved',
});
const conversationDurationMs = performance.now() - conversationStartedAt;
expect(project.id).toBe('project-stable-id');
expect(config).toMatchObject({ schemaVersion: 2, agents: [] });
expect(agent).toMatchObject({
id: 'implementer',
name: 'Implementation Agent',
prompt: '\nPreserve this prompt.\n',
skillIds: ['tdd'],
archivedAt: null,
model: MODEL,
modelResolution: 'resolved',
});
expect(conversation).toMatchObject({
id: 'f47ac10b-58cc-4372-a567-0e02b2c3d479',
agentId: 'implementer',
model: MODEL,
modelResolution: 'resolved',
});
expect(conversation).not.toHaveProperty('piSessionId');
expect(conversation).not.toHaveProperty('sessionKey');
expect(vi.mocked(spawn)).not.toHaveBeenCalled();
expect(projectDurationMs).toBeLessThan(1_000);
expect(agentDurationMs).toBeLessThan(500);
expect(conversationDurationMs).toBeLessThan(500);
});
it('requires an explicit model selection when resolution is required', async () => {
const projectPath = await makeProjectPath();
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => 'project-id',
now: () => NOW,
});
await createLocalCodingProject({ projectPath, now: NOW }, store);
const agent = await createCodingProjectAgent(projectPath, {
id: 'unresolved',
avatarId: 'avatar-02',
roleName: 'Unresolved',
name: 'Unresolved Agent',
model: null,
modelResolution: 'required',
responsibility: RESPONSIBILITY,
}, { now: NEXT });
expect(agent).toMatchObject({ model: null, modelResolution: 'required' });
const read = await readCodingProjectConfigV2(projectPath);
expect(read.status).toBe('valid');
if (read.status === 'valid') {
expect(read.config.agents[0]).toMatchObject({ model: null, modelResolution: 'required' });
}
});
it('preserves one project id across concurrent first opens of the same folder', async () => {
const projectPath = await makeProjectPath();
let createCount = 0;
const store = createCodingProjectStore(createMemoryCodingProjectStorage(), {
createId: () => `project-${++createCount}`,
now: () => NOW,
});
const [first, second] = await Promise.all([
store.openFolder(projectPath),
store.openFolder(projectPath),
]);
expect(first.id).toBe('project-1');
expect(second.id).toBe('project-1');
expect(createCount).toBe(1);
expect(await store.listProjects()).toHaveLength(1);
});
});
describe('coding Conversation schema v2', () => {
it.each([
'../session',
'..\\session',
'/absolute/session',
'C:\\absolute\\session',
'nested/session',
'nested\\session',
' session-opaque ',
])('rejects a non-opaque session key: %s', (sessionKey) => {
expect(() => validateSessionKey(sessionKey)).toThrow('opaque relative key');
});
it('allows only one Pi session creation for concurrent first prompts', async () => {
const projectPath = await makeProjectPath();
const store = createCodingConversationStore(projectPath, {
createId: () => '2c1f4e52-4af8-4fce-a82e-18972ed71b47',
now: () => NOW,
});
const conversation = await store.create({
agentId: 'implementer',
title: 'First prompt',
model: MODEL,
modelResolution: 'resolved',
});
let release!: () => void;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const createBinding = vi.fn(async () => {
await gate;
return { piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' };
});
const first = store.ensureSessionBinding(conversation.id, createBinding);
const second = store.ensureSessionBinding(conversation.id, createBinding);
await vi.waitFor(() => expect(createBinding).toHaveBeenCalledTimes(1));
release();
await expect(Promise.all([first, second])).resolves.toEqual([
expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }),
expect.objectContaining({ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }),
]);
expect(createBinding).toHaveBeenCalledTimes(1);
expect(JSON.parse(await readFile(path.join(projectPath, '.niancode', 'conversations.json'), 'utf8')))
.toMatchObject({
schemaVersion: 2,
conversations: [{ piSessionId: 'pi-session-1', sessionKey: 'session-opaque-1' }],
});
});
it('atomically replaces JSON and cleans its temporary file after a failed replace', async () => {
const projectPath = await makeProjectPath();
const metadataDirectory = path.join(projectPath, '.niancode');
const filePath = path.join(metadataDirectory, 'atomic.json');
await atomicWriteJson(filePath, { version: 1 });
await atomicWriteJson(filePath, { version: 2 });
expect(JSON.parse(await readFile(filePath, 'utf8'))).toEqual({ version: 2 });
const directoryTarget = path.join(metadataDirectory, 'cannot-replace-directory');
await mkdir(directoryTarget);
await expect(atomicWriteJson(directoryTarget, { version: 3 })).rejects.toBeDefined();
expect((await readdir(metadataDirectory)).filter((name) => name.endsWith('.tmp'))).toEqual([]);
});
});