288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import path from 'node:path';
|
|
import type { CodingConversationMetadata } from '../../shared/coding-project-contracts';
|
|
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 CodingConversationMetadata, ConversationModelState {
|
|
piSessionId?: string;
|
|
sessionKey?: 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 setModelState(
|
|
conversationId: string,
|
|
modelState: ConversationModelState,
|
|
): 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,
|
|
...modelState,
|
|
updatedAt: now(),
|
|
});
|
|
return {
|
|
result: updated,
|
|
file: {
|
|
schemaVersion: 2,
|
|
conversations: file.conversations.map((item) => item.id === conversationId ? updated : item),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
async delete(conversationId: string): Promise<void> {
|
|
await mutate(async (file) => {
|
|
const current = file.conversations.find((item) => item.id === conversationId);
|
|
if (!current) throw new Error('Conversation does not exist');
|
|
return {
|
|
result: undefined,
|
|
file: {
|
|
schemaVersion: 2,
|
|
conversations: file.conversations.filter((item) => item.id !== conversationId),
|
|
},
|
|
};
|
|
});
|
|
},
|
|
|
|
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);
|
|
}
|
|
},
|
|
};
|
|
}
|