feat(coding): add durable project identity core

This commit is contained in:
2026-08-26 17:59:44 +08:00
parent 7e54b8fbda
commit 43f58fc72b
12 changed files with 717 additions and 7 deletions

View File

@@ -1,13 +1,17 @@
import { mkdir, readdir, stat, writeFile } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { mkdir, readdir, realpath, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { isProjectType, type ProjectType } from '../../shared/project-config';
import type { ProjectIdentityChoice } from '../../shared/coding-project-contracts';
import {
createCodingConversationStore,
type CodingConversationV2,
} from './conversation-store';
import {
acknowledgeLegacyConversationNotice,
isCanonicalCodingProjectId,
normalizeCodingProjectConfigV2,
normalizeProjectIdentityChoice,
readCodingProjectConfigV2,
writeCodingProjectConfigV2,
type CodingProjectConfigV2,
@@ -18,6 +22,7 @@ import {
} from './migration';
import {
createLocalCodingProject,
normalizeCodingProjectPath,
type CodingProject,
type CodingProjectStore,
} from './project-store';
@@ -50,6 +55,7 @@ export interface CreateCodingProjectRequest {
parentPath?: string;
projectName?: string;
projectType?: ProjectType;
identity: ProjectIdentityChoice;
}
export interface CodingProjectConfigSnapshot {
@@ -59,7 +65,14 @@ export interface CodingProjectConfigSnapshot {
}
export interface CodingProjectServiceOptions {
createProjectId?: () => string;
now?: () => string;
onResourcesChanged?(project: CodingProject): Promise<void> | void;
onProjectIdentityChanging?(
project: CodingProject,
previousProjectId: string | undefined,
nextProjectId: string,
): Promise<void> | void;
onProjectDeactivated?(
project: CodingProject,
reason: 'project_deactivated' | 'project_removed',
@@ -94,12 +107,40 @@ function assertStableConfig(previous: CodingProjectConfigV2, next: CodingProject
if (next.createdAt !== previous.createdAt) {
throw new CodingProjectServiceError(409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', 'Project creation identity cannot be changed');
}
if (next.projectId !== previous.projectId) {
throw new CodingProjectServiceError(409, 'CODING_PROJECT_IDENTITY_IMMUTABLE', 'Project creation identity cannot be changed');
}
const nextIds = new Set(next.agents.map(({ id }) => id));
if (previous.agents.some(({ id }) => !nextIds.has(id))) {
throw new CodingProjectServiceError(409, 'CODING_AGENT_ID_IMMUTABLE', 'Existing Agent ids must be preserved');
}
}
function resolveProjectIdentity(
identity: unknown,
createProjectId: () => string,
): string {
let choice: ProjectIdentityChoice;
try {
choice = normalizeProjectIdentityChoice(identity);
} catch {
throw new CodingProjectServiceError(
400,
'CODING_PROJECT_REQUEST_INVALID',
'Project identity choice is invalid',
);
}
const projectId = choice.kind === 'create' ? createProjectId() : choice.projectId;
if (!isCanonicalCodingProjectId(projectId)) {
throw new CodingProjectServiceError(
400,
'CODING_PROJECT_REQUEST_INVALID',
'Project identity is invalid',
);
}
return projectId;
}
export class CodingProjectService {
private readonly conversationStores = new Map<
string,
@@ -107,6 +148,7 @@ export class CodingProjectService {
>();
private activeTransitionTail = Promise.resolve();
private readonly migrationFlights = new Map<string, Promise<CodingProjectConfigV2>>();
private readonly identityTransitionTails = new Map<string, Promise<void>>();
constructor(
private readonly store: CodingProjectStore,
@@ -133,6 +175,67 @@ export class CodingProjectService {
return project;
}
async requireActiveRealProjectWithIdentity(trustedProjectPath?: string): Promise<{
project: CodingProject;
path: string;
projectId: string;
}> {
const project = await this.requireActiveProject();
let projectPath: string;
try {
projectPath = await realpath(path.resolve(project.path));
} catch {
throw new CodingProjectServiceError(
409,
'CODING_ACTIVE_PROJECT_INVALID',
'The active coding project directory is unavailable',
);
}
const entry = await stat(projectPath).catch(() => null);
if (!entry?.isDirectory()) {
throw new CodingProjectServiceError(
409,
'CODING_ACTIVE_PROJECT_INVALID',
'The active coding project directory is unavailable',
);
}
if (trustedProjectPath !== undefined) {
let trustedRealPath: string;
try {
trustedRealPath = await realpath(path.resolve(trustedProjectPath));
} catch {
throw new CodingProjectServiceError(
409,
'CODING_ACTIVE_PROJECT_PATH_MISMATCH',
'The trusted coding project path does not match the active project',
);
}
if (normalizeCodingProjectPath(trustedRealPath) !== normalizeCodingProjectPath(projectPath)) {
throw new CodingProjectServiceError(
409,
'CODING_ACTIVE_PROJECT_PATH_MISMATCH',
'The trusted coding project path does not match the active project',
);
}
}
const config = await this.readCurrentConfig(projectPath);
if (!config) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_CONFIG_INVALID',
'Coding project configuration is unavailable',
);
}
if (!config.projectId) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_REQUIRED',
'A durable coding project identity is required',
);
}
return { project, path: projectPath, projectId: config.projectId };
}
async getProject(projectId: string): Promise<CodingProject> {
const id = projectId.trim();
const project = (await this.store.listProjects()).find((candidate) => candidate.id === id);
@@ -159,6 +262,10 @@ export class CodingProjectService {
}
async createProject(input: CreateCodingProjectRequest): Promise<CodingProjectConfigSnapshot> {
const projectId = resolveProjectIdentity(
input.identity,
this.options.createProjectId ?? randomUUID,
);
const selectedPath = input.projectPath?.trim();
if (selectedPath && (input.parentPath?.trim() || input.projectName?.trim())) {
throw new CodingProjectServiceError(400, 'CODING_PROJECT_REQUEST_INVALID', 'Project path input is ambiguous');
@@ -184,6 +291,7 @@ export class CodingProjectService {
return await this.transitionActiveProject(async () => {
const { project, config } = await createLocalCodingProject({
projectPath,
projectId,
...(input.projectType ? { projectType: input.projectType } : {}),
}, this.store);
const snapshot = { project, config, knowledgeFiles: [] };
@@ -272,6 +380,97 @@ export class CodingProjectService {
};
}
async resolveProjectIdentity(
localProjectId: string,
identity: ProjectIdentityChoice,
): Promise<CodingProjectConfigSnapshot> {
const project = await this.getProject(localProjectId);
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.getConfig(project.id);
if (current.config.projectId !== undefined) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_IMMUTABLE',
'Project creation identity cannot be changed',
);
}
const nextProjectId = resolveProjectIdentity(
identity,
this.options.createProjectId ?? randomUUID,
);
await this.options.onProjectIdentityChanging?.(
project,
undefined,
nextProjectId,
);
const next = {
...current.config,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
try {
await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next);
} catch (error) {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return {
project: current.project,
config: next,
knowledgeFiles: await this.listKnowledgeFiles(current.project.path),
};
});
}
async makeProjectIndependentCopy(
localProjectId: string,
confirmed: true,
): Promise<CodingProjectConfigSnapshot> {
if (confirmed !== true) {
throw new CodingProjectServiceError(
400,
'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED',
'Independent copy requires explicit confirmation',
);
}
const project = await this.getProject(localProjectId);
return await this.serializeIdentityTransition(project.path, async () => {
const current = await this.getConfig(project.id);
if (current.config.projectId === undefined) {
throw new CodingProjectServiceError(
409,
'CODING_PROJECT_IDENTITY_REQUIRED',
'A durable coding project identity is required',
);
}
const nextProjectId = resolveProjectIdentity(
{ kind: 'create' },
this.options.createProjectId ?? randomUUID,
);
await this.options.onProjectIdentityChanging?.(
project,
current.config.projectId,
nextProjectId,
);
const next = {
...current.config,
projectId: nextProjectId,
updatedAt: this.options.now?.() ?? new Date().toISOString(),
};
try {
await (this.options.writeConfig ?? writeCodingProjectConfigV2)(project.path, next);
} catch (error) {
storageFailure(error);
}
await this.options.onResourcesChanged?.(project);
return {
project: current.project,
config: next,
knowledgeFiles: await this.listKnowledgeFiles(current.project.path),
};
});
}
async addKnowledgeFile(input: {
projectId: string;
fileName: string;
@@ -343,6 +542,21 @@ export class CodingProjectService {
return { project, conversation };
}
private serializeIdentityTransition<T>(
projectPath: string,
operation: () => Promise<T>,
): Promise<T> {
const previous = this.identityTransitionTails.get(projectPath) ?? Promise.resolve();
const result = previous.then(operation, operation);
const tail = result.then(() => undefined, () => undefined);
this.identityTransitionTails.set(projectPath, tail);
return result.finally(() => {
if (this.identityTransitionTails.get(projectPath) === tail) {
this.identityTransitionTails.delete(projectPath);
}
});
}
private async listKnowledgeFiles(projectPath: string): Promise<string[]> {
try {
const entries = await readdir(path.join(projectPath, 'knowledge'), { withFileTypes: true });