Merge permanent conversation deletion and simplified project configuration
This commit is contained in:
commit
c77d8cf3a2
21 files changed
+1104
-326
No files matched your search
@@ -41,6 +41,7 @@ import { createDataServiceOperations } from '../services/data-service-client';
|
||||
import { createPreviewDataSessionManager, type PreviewDataSessionManager } from '../services/preview-data-session';
|
||||
import {
|
||||
archivePiConversationSession,
|
||||
deletePiConversationSession,
|
||||
getPiManagedPaths,
|
||||
} from '../coding-runtime/pi/resource-loader';
|
||||
import { createProjectPluginService } from '../coding-plugins/project-service';
|
||||
@@ -515,6 +516,9 @@ export function createCodingComposition(
|
||||
const conversations = new CodingConversationService(projects, runtime, {
|
||||
readHistory: (projectId, conversation) => readCodingConversationHistory(options.paths.userDataDir, projectId, conversation),
|
||||
onDelete: (projectId, conversationId) => teacher.removeSource(projectId, conversationId),
|
||||
deleteSession: async ({ projectId, sessionKey }) => {
|
||||
await deletePiConversationSession({ userDataDir: options.paths.userDataDir, projectId, sessionKey });
|
||||
},
|
||||
archiveSession: async ({ projectId, sessionKey }) => {
|
||||
await archivePiConversationSession({
|
||||
userDataDir: options.paths.userDataDir,
|
||||
|
||||
@@ -50,6 +50,7 @@ interface AcceptanceRecord {
|
||||
export interface CodingConversationServiceOptions {
|
||||
readHistory?(projectId: string, conversation: CodingConversationV2): Promise<ConversationSnapshot>;
|
||||
onDelete?(projectId: string, conversationId: string): Promise<void>;
|
||||
deleteSession?(input: { projectId: string; sessionKey: string }): Promise<void>;
|
||||
archiveSession?(input: {
|
||||
projectId: string;
|
||||
sessionKey: string;
|
||||
@@ -267,6 +268,7 @@ class PatchBatchScheduler {
|
||||
export class CodingConversationService {
|
||||
private readonly prepareFlights = new Map<string, Promise<PrepareConversationInput>>();
|
||||
private readonly acceptances = new Map<string, AcceptanceRecord>();
|
||||
private readonly deleteFlights = new Map<string, Promise<void>>();
|
||||
|
||||
constructor(
|
||||
readonly projects: CodingProjectService,
|
||||
@@ -307,7 +309,9 @@ export class CodingConversationService {
|
||||
}
|
||||
|
||||
private async assertUnarchived(conversationId: string): Promise<void> {
|
||||
this.assertNotDeleting(conversationId);
|
||||
const { conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
this.assertNotDeleting(conversationId);
|
||||
if (conversation.archivedAt) {
|
||||
throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_ARCHIVED', '请先恢复已归档的对话,再继续操作。');
|
||||
}
|
||||
@@ -380,13 +384,26 @@ export class CodingConversationService {
|
||||
}
|
||||
|
||||
async deleteConversation(conversationId: string): Promise<void> {
|
||||
const { project, conversation } = await this.projects.findActiveConversation(conversationId);
|
||||
const prior = this.deleteFlights.get(conversationId);
|
||||
if (prior) return await prior;
|
||||
const flight = this.performDelete(conversationId).finally(() => {
|
||||
if (this.deleteFlights.get(conversationId) === flight) this.deleteFlights.delete(conversationId);
|
||||
});
|
||||
this.deleteFlights.set(conversationId, flight);
|
||||
return await flight;
|
||||
}
|
||||
|
||||
private async performDelete(conversationId: string): Promise<void> {
|
||||
const { project } = await this.projects.findActiveConversation(conversationId);
|
||||
await this.prepareFlights.get(conversationId)?.catch(() => undefined);
|
||||
try {
|
||||
await this.runtime.dispose(conversationId, 'conversation_deleted');
|
||||
} catch (error) {
|
||||
runtimeError(error);
|
||||
}
|
||||
await this.archiveSession(project.id, conversation.sessionKey);
|
||||
// A prepare already in flight may have created the durable session binding.
|
||||
const conversation = await this.projects.conversationStore(project.path).get(conversationId);
|
||||
await this.deleteSession(project.id, conversation?.sessionKey);
|
||||
await this.options.onDelete?.(project.id, conversationId);
|
||||
await persist(() => this.projects.conversationStore(project.path).delete(conversationId));
|
||||
this.titleContexts.delete(conversationId);
|
||||
@@ -394,6 +411,7 @@ export class CodingConversationService {
|
||||
for (const key of [...this.acceptances.keys()]) {
|
||||
if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key);
|
||||
}
|
||||
this.metadataChanged(project.id, conversationId);
|
||||
}
|
||||
|
||||
async getSnapshot(conversationId: string): Promise<ConversationSnapshot> {
|
||||
@@ -426,6 +444,7 @@ export class CodingConversationService {
|
||||
attachments?: unknown;
|
||||
}): Promise<CodingPromptAcceptance> {
|
||||
const conversationId = requiredString(input.conversationId, 'Conversation id', 128);
|
||||
this.assertNotDeleting(conversationId);
|
||||
const clientRequestId = typeof input.clientRequestId === 'string' ? input.clientRequestId : '';
|
||||
if (!REQUEST_ID_PATTERN.test(clientRequestId)) {
|
||||
throw new CodingConversationServiceError(400, 'CODING_CONVERSATION_REQUEST_INVALID', 'Client request id is invalid');
|
||||
@@ -469,6 +488,7 @@ export class CodingConversationService {
|
||||
try {
|
||||
await this.assertUnarchived(conversationId);
|
||||
await this.ensurePrepared(conversationId);
|
||||
this.assertNotDeleting(conversationId);
|
||||
const acceptance = await this.runtime.prompt({
|
||||
conversationId,
|
||||
clientRequestId,
|
||||
@@ -732,6 +752,7 @@ export class CodingConversationService {
|
||||
|
||||
private ensurePrepared(conversationId: string): Promise<PrepareConversationInput> {
|
||||
const id = conversationId.trim();
|
||||
this.assertNotDeleting(id);
|
||||
const prior = this.prepareFlights.get(id);
|
||||
if (prior) return prior;
|
||||
const flight = (async () => {
|
||||
@@ -756,6 +777,7 @@ export class CodingConversationService {
|
||||
modelResolution: conversation.modelResolution,
|
||||
},
|
||||
};
|
||||
this.assertNotDeleting(id);
|
||||
try { await this.runtime.prepare(prepared); } catch (error) { runtimeError(error); }
|
||||
if (this.titleContexts.has(id)) {
|
||||
const snapshot = await this.runtime.getSnapshot(id);
|
||||
@@ -806,4 +828,18 @@ export class CodingConversationService {
|
||||
}
|
||||
await persist(() => this.options.archiveSession!({ projectId, sessionKey }));
|
||||
}
|
||||
|
||||
private assertNotDeleting(conversationId: string): void {
|
||||
if (this.deleteFlights.has(conversationId)) {
|
||||
throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_DELETING', '对话正在删除,请稍候。');
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteSession(projectId: string, sessionKey: string | undefined): Promise<void> {
|
||||
if (!sessionKey) return;
|
||||
if (!this.options.deleteSession) {
|
||||
throw new CodingConversationServiceError(500, 'CODING_STORAGE_WRITE_FAILED', 'Coding session deletion is unavailable');
|
||||
}
|
||||
await persist(() => this.options.deleteSession!({ projectId, sessionKey }));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, rename, stat } from 'node:fs/promises';
|
||||
import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { atomicWriteJson, atomicWriteText } from '../../coding-projects/atomic-json';
|
||||
import { validateSessionKey } from '../../coding-projects/conversation-store';
|
||||
@@ -166,6 +166,33 @@ export async function archivePiConversationSession(input: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function deletePiConversationSession(input: {
|
||||
userDataDir: string;
|
||||
projectId: string;
|
||||
sessionKey: string;
|
||||
}): Promise<void> {
|
||||
const projectId = managedSegment(input.projectId, 'Project id');
|
||||
const sessionKey = validateSessionKey(input.sessionKey);
|
||||
const paths = getPiManagedPaths(input.userDataDir);
|
||||
const basename = `${sessionKey}.jsonl`;
|
||||
for (const root of [paths.sessionsDir, paths.trashDir]) {
|
||||
const directory = path.join(root, projectId);
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return [];
|
||||
throw error;
|
||||
});
|
||||
for (const entry of entries) {
|
||||
// Pi 0.84 stores sessions as <ISO timestamp>_<session id>.jsonl;
|
||||
// earlier managed sessions used the session id alone.
|
||||
const timestamp = entry.name.endsWith(`_${basename}`)
|
||||
? entry.name.slice(0, -(`_${basename}`.length))
|
||||
: '';
|
||||
if (entry.name !== basename && !/^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z$/u.test(timestamp)) continue;
|
||||
await rm(path.join(directory, entry.name), { force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeSkillEntries(skillEntries: readonly PiSkillEntry[]): PiSkillEntry[] {
|
||||
const result: PiSkillEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
Reference in new issue
Block a user