diff --git a/.project-docs/30-worklog/tasks/20260822-pi-schema-v2-5e4b7a91.md b/.project-docs/30-worklog/tasks/20260822-pi-schema-v2-5e4b7a91.md new file mode 100644 index 0000000..b91e84c --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260822-pi-schema-v2-5e4b7a91.md @@ -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. diff --git a/electron/coding-projects/atomic-json.ts b/electron/coding-projects/atomic-json.ts new file mode 100644 index 0000000..5a34747 --- /dev/null +++ b/electron/coding-projects/atomic-json.ts @@ -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; + +export async function atomicWriteText(filePath: string, source: string): Promise { + 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 { + await atomicWriteText(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +export async function readJsonFile(filePath: string): Promise { + return JSON.parse(await readFile(filePath, 'utf8')) as unknown; +} diff --git a/electron/coding-projects/conversation-store.ts b/electron/coding-projects/conversation-store.ts new file mode 100644 index 0000000..ce86057 --- /dev/null +++ b/electron/coding-projects/conversation-store.ts @@ -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; + 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; + 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>(); + let mutationTail = Promise.resolve(); + + async function readNow(): Promise { + try { + return normalizeCodingConversationFileV2(await readJsonFile(conversationFilePath(projectPath))); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return createEmptyConversationFileV2(); + throw error; + } + } + + function mutate(operation: (file: CodingConversationFileV2) => Promise<{ + result: T; + file: CodingConversationFileV2; + }>): Promise { + 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 { + await mutationTail; + return (await readNow()).conversations.find((item) => item.id === conversationId) ?? null; + } + + return { + async read(): Promise { + await mutationTail; + return await readNow(); + }, + + async create(input: CreateCodingConversationInput): Promise { + 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>, + ): Promise { + 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, + ): Promise { + 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); + } + }, + }; +} diff --git a/electron/coding-projects/migration.ts b/electron/coding-projects/migration.ts new file mode 100644 index 0000000..951dff6 --- /dev/null +++ b/electron/coding-projects/migration.ts @@ -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; + +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 { + 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 { + 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(); + 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 { + 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 { + const failures: string[] = []; + const restore = async (label: string, operation: () => Promise) => { + 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 { + 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), + }; +} diff --git a/electron/coding-projects/project-config.ts b/electron/coding-projects/project-config.ts new file mode 100644 index 0000000..2e2ce80 --- /dev/null +++ b/electron/coding-projects/project-config.ts @@ -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([ + '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; + 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; + 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; + 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(); + 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; + 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 { + 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 { + 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 { + 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 { + 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 { + 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); +} diff --git a/electron/coding-projects/project-store.ts b/electron/coding-projects/project-store.ts new file mode 100644 index 0000000..3d40065 --- /dev/null +++ b/electron/coding-projects/project-store.ts @@ -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; + activeProjectId: string | null; +} + +export interface CodingProjectStorage { + read(): Promise; + write(data: CodingProjectStoreData): Promise; +} + +export interface CodingProjectKeyValueStore { + get(key: string): unknown; + set(key: string, value: CodingProjectStoreData): unknown; +} + +export type CodingProjectStore = ReturnType; + +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 { + return (await storage.read()) ?? emptyStoreData(); + } + + function mutate(operation: () => Promise): Promise { + const result = mutationTail.then(operation, operation); + mutationTail = result.then(() => undefined, () => undefined); + return result; + } + + async function upsertProject(input: string, activate: boolean): Promise { + 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 { + return await upsertProject(input, true); + }, + + async rememberProject(input: string): Promise { + return await upsertProject(input, false); + }, + + async listProjects(): Promise { + await mutationTail; + return Object.values((await readData()).projects) + .sort((left, right) => right.lastOpenedAt.localeCompare(left.lastOpenedAt)); + }, + + async getActiveProject(): Promise { + await mutationTail; + const data = await readData(); + return data.activeProjectId ? data.projects[data.activeProjectId] ?? null : null; + }, + + async setActiveProject(projectId: string | null): Promise { + 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 { + 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 }; +} diff --git a/tests/unit/coding-projects-migration.test.ts b/tests/unit/coding-projects-migration.test.ts new file mode 100644 index 0000000..e78f08f --- /dev/null +++ b/tests/unit/coding-projects-migration.test.ts @@ -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 { + 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: [] }); + }); +}); diff --git a/tests/unit/coding-projects-schema-v2.test.ts b/tests/unit/coding-projects-schema-v2.test.ts new file mode 100644 index 0000000..d5213ca --- /dev/null +++ b/tests/unit/coding-projects-schema-v2.test.ts @@ -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 { + 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((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([]); + }); +});