diff --git a/CONTEXT.md b/CONTEXT.md index 91f91a11..de8c8c83 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -240,6 +240,10 @@ _Avoid_: main view, page, layout mode A user-visible, Project-owned Excalidraw document composed of independently editable shapes, text, and connectors that serves as the shared source for Agent generation and user editing. Subsequent Agent changes apply to the current document rather than regenerating a separate copy; the capability is available across Scenes. _Avoid_: Scientific Figure, generated image, flattened flowchart, hidden artifact, Research Scene tool +**Flow Diagram Document Version**: +An opaque identity of the exact current contents of an Editable Flow Diagram, used to detect whether a save or Agent edit is still based on that document. It is not ordered and does not represent history. +_Avoid_: Flow Diagram Revision, timestamp, save counter, version number, Project commit + **Flow Diagram Revision**: A durable snapshot of an Editable Flow Diagram captured immediately before an Agent modifies it and retained independently of the user's Project version control. It is available to Agent operations and automatic recovery without exposing version management or manual rollback controls to the user. _Avoid_: Project commit, copied backup file, Excalidraw undo entry, user-facing version history, full-Project snapshot diff --git a/src/main/academic-style-revision-skill.test.ts b/src/main/academic-style-revision-skill.test.ts index 4f9abc02..83dff753 100644 --- a/src/main/academic-style-revision-skill.test.ts +++ b/src/main/academic-style-revision-skill.test.ts @@ -76,7 +76,7 @@ describe('Academic Style Revision Skill', () => { expect(fs.readFileSync(path.join(skillDir as string, 'SKILL.md'), 'utf-8')).toBe(markdown); expect(fs.existsSync(path.join(skillDir as string, 'scripts'))).toBe(false); expect(fs.readFileSync(path.join(skillDir as string, 'PROVENANCE.md'), 'utf-8')).toContain('MIT'); - }); + }, 15_000); it('publishes scope, fidelity, coverage, and report safety contracts without executable capabilities', () => { const markdown = getAcademicStyleRevisionSkillMarkdown(); diff --git a/src/main/at-mention/candidate-lister.test.ts b/src/main/at-mention/candidate-lister.test.ts index 738aaed7..7c330b4c 100644 --- a/src/main/at-mention/candidate-lister.test.ts +++ b/src/main/at-mention/candidate-lister.test.ts @@ -121,7 +121,7 @@ describe('candidate-lister', () => { const result = listCandidates(tempDir); expect(result.candidates.length).toBe(5000); expect(result.truncated).toBe(true); - }, 15_000); + }, 30_000); // Phase 08.3 fix #6: symlink-traversal guard. it('drops symlink paths that resolve outside the project root', () => { diff --git a/src/main/cdf-file-protocol.test.ts b/src/main/cdf-file-protocol.test.ts index b06e26fc..48294172 100644 --- a/src/main/cdf-file-protocol.test.ts +++ b/src/main/cdf-file-protocol.test.ts @@ -7,7 +7,6 @@ import { pathToFileURL } from 'node:url'; import { contentTypeForPath, createCdfFileResponse, - isPathWithinRoots, parseRangeHeader, resolveCdfFilePath, } from './cdf-file-protocol'; @@ -36,40 +35,6 @@ describe('resolveCdfFilePath', () => { }); }); -describe('isPathWithinRoots', () => { - // Regression (#204 回归): under standard:true Chromium folds the first path - // segment into the URL host and lowercases it, so every macOS request arrives - // as /users/... while allowedRoots hold /Users/.... On a case-insensitive - // filesystem both address the same file, so containment must not be - // case-sensitive — otherwise every historical image/audio 403s. - it('accepts a host-casefolded path on a case-insensitive filesystem', () => { - expect(isPathWithinRoots( - '/users/suntc/Library/Application Support/cdf/default-project/.cdf/artifacts/images/a.png', - ['/Users/suntc/Library/Application Support/cdf'], - true, - )).toBe(true); - }); - - it('still rejects casing differences in case-sensitive mode', () => { - expect(isPathWithinRoots( - '/users/suntc/Library/Application Support/cdf/a.png', - ['/Users/suntc/Library/Application Support/cdf'], - false, - )).toBe(false); - }); - - it('still rejects escapes and unrelated roots regardless of casing mode', () => { - expect(isPathWithinRoots('/Users/suntc/other/a.png', ['/Users/suntc/Library'], true)).toBe(false); - expect(isPathWithinRoots('/Users/suntc/Library/../.ssh/id_rsa', ['/Users/suntc/Library'], true)).toBe(false); - expect(isPathWithinRoots('/Users/suntc/LibraryEvil/a.png', ['/Users/suntc/Library'], true)).toBe(false); - }); - - it('defaults to case-insensitive containment on macOS/Windows', () => { - const expected = process.platform === 'darwin' || process.platform === 'win32'; - expect(isPathWithinRoots('/users/x/a.png', ['/Users/x'])).toBe(expected); - }); -}); - describe('parseRangeHeader', () => { it('returns null when there is no Range header', () => { expect(parseRangeHeader(null, 1000)).toBeNull(); @@ -199,6 +164,73 @@ describe('createCdfFileResponse', () => { expect(res.status).toBe(403); }); + it('rejects a sibling directory that differs from the allowed root only by casing when both can exist', async () => { + const allowedRoot = path.join(tempDir, 'Allowed'); + const siblingRoot = path.join(tempDir, 'allowed'); + fs.mkdirSync(allowedRoot); + if (fs.existsSync(siblingRoot)) { + // The current volume is case-insensitive; the macOS host-fold regression below + // exercises that filesystem identity. Case-sensitive CI/volumes continue here. + return; + } + fs.mkdirSync(siblingRoot); + const siblingFile = path.join(siblingRoot, 'clip.mp4'); + fs.writeFileSync(siblingFile, CONTENT); + + const res = await createCdfFileResponse({ + url: cdfUrl(siblingFile), + rangeHeader: null, + allowedRoots: [allowedRoot], + }); + + expect(res.status).toBe(403); + }); + + it('returns 403 for a path whose directory only shares the allowed root prefix', async () => { + const allowedRoot = path.join(tempDir, 'Library'); + const prefixedSibling = path.join(tempDir, 'LibraryEvil'); + fs.mkdirSync(allowedRoot); + fs.mkdirSync(prefixedSibling); + const siblingFile = path.join(prefixedSibling, 'clip.mp4'); + fs.writeFileSync(siblingFile, CONTENT); + + const res = await createCdfFileResponse({ + url: cdfUrl(siblingFile), + rangeHeader: null, + allowedRoots: [allowedRoot], + }); + + expect(res.status).toBe(403); + }); + + it.runIf(process.platform !== 'win32')( + 'returns 403 for ordinary and Range requests through a symlink that points outside the root', + async () => { + const allowedRoot = path.join(tempDir, 'allowed'); + const outsidePath = path.join(tempDir, 'outside.mp4'); + const linkedPath = path.join(allowedRoot, 'linked.mp4'); + fs.mkdirSync(allowedRoot); + fs.writeFileSync(outsidePath, CONTENT); + fs.symlinkSync(outsidePath, linkedPath); + + const [ordinaryResponse, rangeResponse] = await Promise.all([ + createCdfFileResponse({ + url: cdfUrl(linkedPath), + rangeHeader: null, + allowedRoots: [allowedRoot], + }), + createCdfFileResponse({ + url: cdfUrl(linkedPath), + rangeHeader: 'bytes=0-5', + allowedRoots: [allowedRoot], + }), + ]); + + expect(ordinaryResponse.status).toBe(403); + expect(rangeResponse.status).toBe(403); + }, + ); + // Regression (#204 回归): simulate Chromium's host casefolding — the URL path // casing differs from the allowedRoots casing, but the case-insensitive macOS // filesystem still resolves the same file. Must serve 200, not 403. diff --git a/src/main/cdf-file-protocol.ts b/src/main/cdf-file-protocol.ts index 0c3e912f..625d47c7 100644 --- a/src/main/cdf-file-protocol.ts +++ b/src/main/cdf-file-protocol.ts @@ -145,33 +145,59 @@ export interface CdfFileRequest { allowedRoots: string[]; } +interface CanonicalPath { + path: string; + exists: boolean; +} + /** - * macOS/Windows 的默认文件系统大小写不敏感:`/users/…` 与 `/Users/…` 指向同一文件。 - * standard scheme 下 Chromium 会把第一段路径折进 URL host 并小写化 - * (`cdf-file:///Users/…` → `cdf-file://users/…`),因此白名单包含性判断必须 - * 同样按大小写不敏感比较,否则所有 `/Users/…` 资源都会被 403。 + * 把路径解析成文件系统认可的真实路径。目标不存在时,从最近的已有父目录继续解析, + * 这样既能让允许根内的缺失文件返回 404,也不会把 `..` 或父目录软链接误判为允许路径。 */ -const CASE_INSENSITIVE_FILESYSTEM = process.platform === 'darwin' || process.platform === 'win32'; +async function resolveCanonicalPath(filePath: string): Promise { + let current = path.resolve(filePath); + const missingSegments: string[] = []; + + while (true) { + try { + const canonicalParent = await fs.promises.realpath(current); + return { + path: path.join(canonicalParent, ...missingSegments), + exists: missingSegments.length === 0, + }; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'ENOENT' && code !== 'ENOTDIR') return null; + + const parent = path.dirname(current); + if (parent === current) return null; + missingSegments.unshift(path.basename(current)); + current = parent; + } + } +} /** - * 判断解析后的绝对路径是否落在任一允许根内。先 `path.resolve` 折叠 `..`, - * 再用 `path.relative` 做包含性判断,杜绝 `/root/../../etc/passwd` 之类逃逸。 - * `caseInsensitive` 默认跟随平台文件系统语义(darwin/win32 不敏感)。 + * `realpath` 已经按当前卷和目录的真实大小写返回路径,因此这里只做逐段精确比较。 + * Windows 盘符本身不区分大小写,但启用 per-directory case sensitivity 的目录段必须区分。 */ -export function isPathWithinRoots( - filePath: string, - allowedRoots: string[], - caseInsensitive: boolean = CASE_INSENSITIVE_FILESYSTEM, -): boolean { - const resolved = path.resolve(filePath); - const target = caseInsensitive ? resolved.toLowerCase() : resolved; - return allowedRoots.some((root) => { - if (!root) return false; - const normalizedRoot = path.resolve(root); - const comparableRoot = caseInsensitive ? normalizedRoot.toLowerCase() : normalizedRoot; - const rel = path.relative(comparableRoot, target); - return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); - }); +function isCanonicalPathWithinRoot(target: string, root: string): boolean { + const normalizedTarget = path.resolve(target); + const normalizedRoot = path.resolve(root); + const targetRoot = path.parse(normalizedTarget).root; + const allowedRoot = path.parse(normalizedRoot).root; + const sameVolume = + process.platform === 'win32' + ? targetRoot.toLowerCase() === allowedRoot.toLowerCase() + : targetRoot === allowedRoot; + if (!sameVolume) return false; + + const targetSegments = normalizedTarget.slice(targetRoot.length).split(path.sep).filter(Boolean); + const rootSegments = normalizedRoot.slice(allowedRoot.length).split(path.sep).filter(Boolean); + return ( + rootSegments.length <= targetSegments.length + && rootSegments.every((segment, index) => segment === targetSegments[index]) + ); } /** @@ -181,14 +207,28 @@ export function isPathWithinRoots( */ export async function createCdfFileResponse(request: CdfFileRequest): Promise { const filePath = resolveCdfFilePath(request.url); + const canonicalTarget = await resolveCanonicalPath(filePath); + if (!canonicalTarget) { + return new Response('File not found', { status: 404 }); + } - if (!isPathWithinRoots(filePath, request.allowedRoots)) { + const canonicalRoots = ( + await Promise.all( + request.allowedRoots + .filter(Boolean) + .map(async (root) => (await resolveCanonicalPath(root))?.path ?? null) + ) + ).filter((root): root is string => root !== null); + if (!canonicalRoots.some((root) => isCanonicalPathWithinRoot(canonicalTarget.path, root))) { return new Response('Forbidden', { status: 403 }); } + if (!canonicalTarget.exists) { + return new Response('File not found', { status: 404 }); + } let stat: fs.Stats; try { - stat = await fs.promises.stat(filePath); + stat = await fs.promises.stat(canonicalTarget.path); } catch { return new Response('File not found', { status: 404 }); } @@ -207,7 +247,7 @@ export async function createCdfFileResponse(request: CdfFileRequest): Promise ({ - getBuiltInSkillDirsMock: vi.fn(() => ['/tmp/built-in/knowledge-base']), - getScopePathMock: vi.fn(() => '/tmp/global-skills'), - resolveSkillSourcePlanMock: vi.fn(() => ({ config: { version: 1, additionalSkillDirectories: [] }, sources: [], warnings: [] })), - resolveSkillCatalogMock: vi.fn((): any => ({ skills: [], warnings: [] })), +const { resolveProjectSkillCatalogMock } = vi.hoisted(() => ({ + resolveProjectSkillCatalogMock: vi.fn((): any => ({ skills: [], warnings: [] })), })); -vi.mock('../../deepagent/skill-manager', () => ({ - getBuiltInSkillDirs: getBuiltInSkillDirsMock, - getScopePath: getScopePathMock, -})); -vi.mock('../../deepagent/skills-runtime/skill-sources', () => ({ - resolveSkillSourcePlan: resolveSkillSourcePlanMock, - resolveSkillCatalog: resolveSkillCatalogMock, -})); +vi.mock('../../deepagent/skill-catalog', async () => { + const sources = await vi.importActual< + typeof import('../../deepagent/skills-runtime/skill-sources') + >('../../deepagent/skills-runtime/skill-sources'); + return { + resolveProjectSkillCatalog: resolveProjectSkillCatalogMock, + getSkillSourceLabel: sources.getSkillSourceLabel, + }; +}); import { collectSkillCommands } from './skill'; describe('collectSkillCommands', () => { beforeEach(() => { vi.clearAllMocks(); - resolveSkillCatalogMock.mockReturnValue({ skills: [], warnings: [] }); + resolveProjectSkillCatalogMock.mockReturnValue({ skills: [], warnings: [] }); }); - it('resolves the live catalog from Built-in and Global sources when no snapshot exists', async () => { - await expect(collectSkillCommands('/tmp/project')).resolves.toEqual([]); + it('resolves the live catalog through the Skill Catalog when no snapshot exists', async () => { + await expect(collectSkillCommands('/tmp/project', { includeNestedProjectSkills: true })) + .resolves.toEqual([]); - expect(resolveSkillSourcePlanMock).toHaveBeenCalledWith('/tmp/project', { - builtInSkillDirs: ['/tmp/built-in/knowledge-base'], - userSkillsDir: '/tmp/global-skills', - includeNestedProjectSkills: undefined, - }); + expect(resolveProjectSkillCatalogMock).toHaveBeenCalledWith( + '/tmp/project', + expect.objectContaining({ includeNestedProjectSkills: true }), + ); }); it('uses the frozen Conversation Skill Snapshot without resolving a live catalog', async () => { @@ -46,11 +44,11 @@ describe('collectSkillCommands', () => { }] }); expect(commands).toEqual([expect.objectContaining({ name: 'review', target: 'project:review' })]); - expect(resolveSkillSourcePlanMock).not.toHaveBeenCalled(); + expect(resolveProjectSkillCatalogMock).not.toHaveBeenCalled(); }); it('maps a Project Skill to an attributable command', async () => { - resolveSkillCatalogMock.mockReturnValue({ skills: [{ + resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{ name: 'simplify', description: 'Simplify code', sourceKind: 'project', sourcePath: '/tmp/project/.cdf/skills', skillPath: '/tmp/project/.cdf/skills/simplify/SKILL.md', modelDiscovery: 'full', userInvocable: true, argumentHint: '', @@ -66,7 +64,7 @@ describe('collectSkillCommands', () => { }); it('maps a Global Skill to a global attributable command', async () => { - resolveSkillCatalogMock.mockReturnValue({ skills: [{ + resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{ name: 'explore', description: 'Explore the repository', sourceKind: 'user', sourcePath: '/tmp/global-skills', skillPath: '/tmp/global-skills/explore/SKILL.md', modelDiscovery: 'full', userInvocable: true, @@ -81,8 +79,29 @@ describe('collectSkillCommands', () => { }); }); + it('maps an Enterprise Skill to the canonical Managed Skill attribution', async () => { + resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{ + name: 'compliance', qualifiedName: 'acme:compliance', qualifier: 'acme', + description: 'Apply managed compliance policy', sourceKind: 'enterprise', + sourcePath: '/tmp/managed-skills', skillPath: '/tmp/managed-skills/compliance/SKILL.md', + modelDiscovery: 'full', userInvocable: true, + }], warnings: [] }); + + const commands = await collectSkillCommands('/tmp/project'); + + expect(commands[0]).toMatchObject({ + name: 'acme:compliance', + qualifiedName: 'acme:compliance', + skillName: 'compliance', + skillSourceKind: 'enterprise', + source: 'skill:global', + target: 'enterprise:acme:compliance', + sourceLabel: 'Managed Skill', + }); + }); + it('omits Skills whose author disables explicit invocation', async () => { - resolveSkillCatalogMock.mockReturnValue({ skills: [{ + resolveProjectSkillCatalogMock.mockReturnValue({ skills: [{ name: 'internal', description: 'Internal workflow', sourceKind: 'project', sourcePath: '/tmp/project/.cdf/skills', skillPath: '/tmp/project/.cdf/skills/internal/SKILL.md', modelDiscovery: 'full', userInvocable: false, diff --git a/src/main/commands/collectors/skill.ts b/src/main/commands/collectors/skill.ts index 9b30e2a7..7acb83e1 100644 --- a/src/main/commands/collectors/skill.ts +++ b/src/main/commands/collectors/skill.ts @@ -1,10 +1,9 @@ -import { getBuiltInSkillDirs, getScopePath } from '../../deepagent/skill-manager'; import { - resolveSkillCatalog, - resolveSkillSourcePlan, + getSkillSourceLabel, + resolveProjectSkillCatalog, type SkillCatalogOptions, type ResolvedSkillCatalogEntry, -} from '../../deepagent/skills-runtime/skill-sources'; +} from '../../deepagent/skill-catalog'; import type { CommandSource, SkillCommandSourceKind, SlashCommand } from '../../../shared/types'; import type { ConversationSkillSnapshotEntry } from '../../../shared/skills'; @@ -25,11 +24,7 @@ export async function collectSkillCommands( ): Promise { const skills = options.catalog ? options.catalog - : resolveSkillCatalog(resolveSkillSourcePlan(projectPath, { - builtInSkillDirs: getBuiltInSkillDirs(), - userSkillsDir: getScopePath(projectPath, 'global'), - includeNestedProjectSkills: options.includeNestedProjectSkills, - }), options).skills; + : resolveProjectSkillCatalog(projectPath, options).skills; return skills .filter((skill) => skill.userInvocable) @@ -52,7 +47,7 @@ function skillToCommand(skill: ResolvedSkillCatalogEntry | ConversationSkillSnap description: skill.description, source, target: `${getTargetScope(skill.sourceKind)}:${qualifiedName}`, - sourceLabel: getSourceLabel(skill), + sourceLabel: getSkillSourceLabel(skill), badge: `[${source}]`, frontmatter: { allowedTools: skill.allowedTools ?? [], @@ -92,19 +87,3 @@ function getTargetScope(sourceKind: ResolvedSkillCatalogEntry['sourceKind']): st } } -function getSourceLabel(skill: Pick): string { - switch (skill.sourceKind) { - case 'built-in': - return 'Built-in Skill'; - case 'project': - return 'Project Skill'; - case 'project-nested': - return skill.qualifier ? `Nested Project Skill: ${skill.qualifier}` : 'Nested Project Skill'; - case 'project-additional': - return skill.qualifier ? `Project Skill: ${skill.qualifier}` : 'Project Skill'; - case 'user': - return 'Global Skill'; - case 'enterprise': - return 'Enterprise Skill'; - } -} diff --git a/src/main/commands/command-registry.ts b/src/main/commands/command-registry.ts index 93a23e22..83aa91b6 100644 --- a/src/main/commands/command-registry.ts +++ b/src/main/commands/command-registry.ts @@ -3,7 +3,7 @@ import { collectMcpCommands } from './collectors/mcp'; import { collectProjectCommands } from './collectors/project'; import { collectSkillCommands, type SkillCommandCollectorOptions } from './collectors/skill'; import { collectSystemCommands } from './collectors/system'; -import type { SkillCatalogOptions } from '../deepagent/skills-runtime/skill-sources'; +import type { SkillCatalogOptions } from '../deepagent/skill-catalog'; import type { ConversationSkillSnapshotEntry } from '../../shared/skills'; import { detectConflicts } from './conflict-detector'; diff --git a/src/main/conversation-system-context-snapshot.ts b/src/main/conversation-system-context-snapshot.ts index e01e5f9b..8d3cb555 100644 --- a/src/main/conversation-system-context-snapshot.ts +++ b/src/main/conversation-system-context-snapshot.ts @@ -1,69 +1,25 @@ import type Database from 'better-sqlite3'; -import { - getBuiltInSkillDirs, - getScopePath, -} from './deepagent/skill-manager'; import { createGlobalSkillSceneExposureFilter } from './global-skill-scene-exposure'; -import { - resolveSkillCatalog, - resolveSkillSourcePlan, - type ResolvedSkillCatalogEntry, -} from './deepagent/skills-runtime/skill-sources'; +import { captureConversationSkillSnapshot } from './deepagent/skill-catalog'; import type { ProjectScene } from '../shared/types'; -import type { - ConversationSkillSnapshotEntry, - GlobalSkillSourceKind, - SkillSourceKind, -} from '../shared/skills'; +import type { ConversationSkillSnapshotEntry } from '../shared/skills'; export interface ConversationSystemContextSnapshot { promptSnapshot: string; skillSnapshot: ConversationSkillSnapshotEntry[]; } -function isGlobalSkillSourceKind(sourceKind: SkillSourceKind): sourceKind is GlobalSkillSourceKind { - return sourceKind === 'built-in' || sourceKind === 'user'; -} - - -function snapshotSkill(skill: ResolvedSkillCatalogEntry): ConversationSkillSnapshotEntry { - return { - name: skill.name, - qualifiedName: skill.qualifiedName, - qualifier: skill.qualifier, - description: skill.description, - argumentHint: skill.argumentHint, - allowedTools: skill.allowedTools, - whenToUse: skill.whenToUse, - arguments: skill.arguments, - sourceKind: skill.sourceKind, - sourcePath: skill.sourcePath, - skillPath: skill.skillPath, - modelDiscovery: skill.modelDiscovery, - userInvocable: skill.userInvocable, - }; -} - export function captureConversationSystemContextSnapshot(input: { projectPath: string; sceneId: ProjectScene; promptSnapshot: string; }): ConversationSystemContextSnapshot { - const plan = resolveSkillSourcePlan(input.projectPath, { - builtInSkillDirs: getBuiltInSkillDirs(), - userSkillsDir: getScopePath(input.projectPath, 'global'), - includeNestedProjectSkills: true, - }); - const isGlobalSkillExposed = createGlobalSkillSceneExposureFilter(input.sceneId); - const catalog = resolveSkillCatalog(plan, { - includeSkill: (source, name) => !isGlobalSkillSourceKind(source.kind) - || isGlobalSkillExposed({ sourceKind: source.kind, name }), - includeNestedProjectSkills: true, - }); - return { promptSnapshot: input.promptSnapshot, - skillSnapshot: catalog.skills.map(snapshotSkill), + skillSnapshot: captureConversationSkillSnapshot({ + projectPath: input.projectPath, + isGlobalSkillExposed: createGlobalSkillSceneExposureFilter(input.sceneId), + }), }; } diff --git a/src/main/deepagent/agent-tools.ts b/src/main/deepagent/agent-tools.ts index 9d02ec91..617ea72b 100644 --- a/src/main/deepagent/agent-tools.ts +++ b/src/main/deepagent/agent-tools.ts @@ -2,7 +2,7 @@ import { tool } from '@langchain/core/tools'; import { z } from 'zod'; import db from '../database'; import { createAgentCatalog, type CatalogAgent } from '../agent-catalog'; -import { listGlobalSkillViews } from './skill-manager'; +import { listGlobalSkillViews } from './skill-catalog'; const AGENT_NAME_REGEX = /^[A-Za-z0-9\s\-_]+$/; diff --git a/src/main/deepagent/context-aggregator.ts b/src/main/deepagent/context-aggregator.ts index 1c3aed1e..d870235e 100644 --- a/src/main/deepagent/context-aggregator.ts +++ b/src/main/deepagent/context-aggregator.ts @@ -17,13 +17,16 @@ import fs from 'fs'; import log from '../logger'; import path from 'path'; import db from '../database'; -import { getBuiltInSkillDirs, getScopePath } from './skill-manager'; +import { + buildProjectSkillsRuntime, + getSkillDisplayName, + getSkillSourceLabel, + type ResolvedSkillCatalogEntry, +} from './skill-catalog'; import { loadMcpTools } from './mcp-connector'; import { getAgentMcpServers, getConnectedMcpServers } from './mcp-visibility'; import type { MCPServer } from '../../shared/types'; import { skillReferencesToPreloadNames } from '../../shared/skill-identifiers'; -import { buildCdfSkillsRuntime } from './skills-runtime/cdf-skills-runtime'; -import type { ResolvedSkillCatalogEntry } from './skills-runtime/skill-sources'; import { getOrCaptureConversationSystemContextSnapshot } from '../conversation-system-context-snapshot'; import { createAgentCatalog } from '../agent-catalog'; import { buildProjectContext } from './project-context'; @@ -138,27 +141,6 @@ function stripSkillFrontmatter(content: string): string { : content.slice(end + '\n---'.length).replace(/^\s+/, ''); } -function getSkillDisplayName(skill: ResolvedSkillCatalogEntry): string { - return skill.qualifiedName ?? skill.name; -} - -function getSkillSourceLabel(skill: ResolvedSkillCatalogEntry): string { - switch (skill.sourceKind) { - case 'built-in': - return 'Built-in Skill'; - case 'project': - return 'Project Skill'; - case 'project-nested': - return skill.qualifier ? `Nested Project Skill: ${skill.qualifier}` : 'Nested Project Skill'; - case 'project-additional': - return skill.qualifier ? `Project Skill: ${skill.qualifier}` : 'Project Skill'; - case 'user': - return 'Global Skill'; - case 'enterprise': - return 'Managed Skill'; - } -} - function isPreloadedSkill(skill: ResolvedSkillCatalogEntry, preloadSkillNames: string[]): boolean { const preloadNames = new Set(preloadSkillNames); const displayName = getSkillDisplayName(skill); @@ -684,10 +666,8 @@ export async function aggregateCurrentSessionContext( sceneId: project.scene ?? 'general', promptSnapshot: createAgentCatalog(db, { initializeSchema: false }).resolveMaster(project.scene ?? 'general').system_prompt, }).skillSnapshot; - const skillsRuntime = buildCdfSkillsRuntime(projectPath, { + const skillsRuntime = buildProjectSkillsRuntime(projectPath, { catalog: skillSnapshot as ResolvedSkillCatalogEntry[], - builtInSkillDirs: getBuiltInSkillDirs(), - userSkillsDir: getScopePath(projectPath, 'global'), preloadSkillNames, }); for (const warning of skillsRuntime.warnings) { diff --git a/src/main/deepagent/conversation-working-state-compaction-runner.test.ts b/src/main/deepagent/conversation-working-state-compaction-runner.test.ts deleted file mode 100644 index a7a34689..00000000 --- a/src/main/deepagent/conversation-working-state-compaction-runner.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { EventEmitter } from 'events'; -import { describe, expect, it, vi } from 'vitest'; -import { - ConversationWorkingStateCompactionRunner, - type ConversationWorkingStateCompactionWorker, -} from './conversation-working-state-compaction-runner'; - -class FakeWorker extends EventEmitter implements ConversationWorkingStateCompactionWorker { - unref(): void {} -} - -const request = { - checkpointDatabasePath: '/tmp/deepagents-checkpoints.db', - liveThreadIds: ['conversation-1'], -}; - -describe('ConversationWorkingStateCompactionRunner', () => { - it('runs compaction in a Worker and forwards real maintenance phases', async () => { - const worker = new FakeWorker(); - const onPhase = vi.fn(); - const runner = new ConversationWorkingStateCompactionRunner( - () => '/app/compaction-worker.js', - (_workerPath, workerRequest) => { - expect(workerRequest).toEqual(request); - queueMicrotask(() => { - worker.emit('message', { type: 'phase', phase: 'rebuilding' }); - worker.emit('message', { - type: 'result', - result: { physicalBytesBefore: 4096, physicalBytesAfter: 2048 }, - }); - }); - return worker; - } - ); - - await expect(runner.run(request, onPhase)).resolves.toEqual({ - physicalBytesBefore: 4096, - physicalBytesAfter: 2048, - }); - expect(onPhase).toHaveBeenCalledWith('rebuilding'); - }); - - it('preserves a stable Worker failure code', async () => { - const worker = new FakeWorker(); - const runner = new ConversationWorkingStateCompactionRunner( - () => '/app/compaction-worker.js', - () => { - queueMicrotask(() => worker.emit('message', { - type: 'error', - code: 'INSUFFICIENT_DISK_SPACE', - error: 'not enough room', - })); - return worker; - } - ); - - await expect(runner.run(request)).rejects.toMatchObject({ - code: 'INSUFFICIENT_DISK_SPACE', - message: 'not enough room', - }); - }); -}); diff --git a/src/main/deepagent/conversation-working-state-compaction-runner.ts b/src/main/deepagent/conversation-working-state-compaction-runner.ts deleted file mode 100644 index b2737b08..00000000 --- a/src/main/deepagent/conversation-working-state-compaction-runner.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { Worker } from 'worker_threads'; -import type { - ConversationWorkingStateFailureReason, - ConversationWorkingStateMaintenancePhase, -} from '../../shared/conversation-working-state'; -import type { - ConversationWorkingStateCompactionRequest, - ConversationWorkingStateCompactionResult, -} from './conversation-working-state-compaction'; -import { ConversationWorkingStateCompactionError } from './conversation-working-state-compaction'; - -export type ConversationWorkingStateCompactionWorkerResponse = - | { type: 'phase'; phase: ConversationWorkingStateMaintenancePhase } - | { type: 'result'; result: ConversationWorkingStateCompactionResult } - | { type: 'error'; code: ConversationWorkingStateFailureReason; error: string }; - -export interface ConversationWorkingStateCompactionWorker { - unref(): void; - on(event: 'message', listener: (message: ConversationWorkingStateCompactionWorkerResponse) => void): this; - once(event: 'error', listener: (error: Error) => void): this; - once(event: 'exit', listener: (code: number) => void): this; -} - -type WorkerFactory = ( - workerPath: string, - request: ConversationWorkingStateCompactionRequest -) => ConversationWorkingStateCompactionWorker; - -const createNodeWorker: WorkerFactory = (workerPath, request) => - new Worker(workerPath, { workerData: request }); - -export interface ConversationWorkingStateCompactionRunnerContract { - run( - request: ConversationWorkingStateCompactionRequest, - onPhase?: (phase: ConversationWorkingStateMaintenancePhase) => void - ): Promise; -} - -export class ConversationWorkingStateCompactionRunner -implements ConversationWorkingStateCompactionRunnerContract { - constructor( - private readonly resolveWorkerPath: () => string, - private readonly createWorker: WorkerFactory = createNodeWorker - ) {} - - run( - request: ConversationWorkingStateCompactionRequest, - onPhase?: (phase: ConversationWorkingStateMaintenancePhase) => void - ): Promise { - return new Promise((resolve, reject) => { - const worker = this.createWorker(this.resolveWorkerPath(), request); - worker.unref(); - let settled = false; - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - callback(); - }; - - worker.on('message', (message) => { - if (message.type === 'phase') { - if (!settled) onPhase?.(message.phase); - return; - } - settle(() => { - if (message.type === 'result') { - resolve(message.result); - } else { - reject(new ConversationWorkingStateCompactionError(message.code, message.error)); - } - }); - }); - worker.once('error', (error) => settle(() => reject(error))); - worker.once('exit', (code) => { - settle(() => reject(new Error( - code === 0 - ? 'Conversation Working State compaction Worker exited without a result.' - : `Conversation Working State compaction Worker exited with code ${code}.` - ))); - }); - }); - } -} diff --git a/src/main/deepagent/conversation-working-state-compaction-worker.ts b/src/main/deepagent/conversation-working-state-compaction-worker.ts index e9026cc1..d4cf6f10 100644 --- a/src/main/deepagent/conversation-working-state-compaction-worker.ts +++ b/src/main/deepagent/conversation-working-state-compaction-worker.ts @@ -3,9 +3,9 @@ import { compactConversationWorkingStateStorage, ConversationWorkingStateCompactionError, type ConversationWorkingStateCompactionRequest, + type ConversationWorkingStateCompactionWorkerResponse, } from './conversation-working-state-compaction'; import { CONVERSATION_WORKING_STATE_FAILURE_REASONS } from '../../shared/conversation-working-state'; -import type { ConversationWorkingStateCompactionWorkerResponse } from './conversation-working-state-compaction-runner'; if (!parentPort) { throw new Error('Conversation Working State compaction requires a Worker parent port.'); diff --git a/src/main/deepagent/conversation-working-state-compaction.test.ts b/src/main/deepagent/conversation-working-state-compaction.test.ts index 483a2052..ba25f5f0 100644 --- a/src/main/deepagent/conversation-working-state-compaction.test.ts +++ b/src/main/deepagent/conversation-working-state-compaction.test.ts @@ -5,11 +5,15 @@ import { spawn } from 'child_process'; import Database from 'better-sqlite3'; import { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite'; import type { Checkpoint, CheckpointMetadata } from '@langchain/langgraph-checkpoint'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { EventEmitter } from 'events'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { compactConversationWorkingStateStorage, + ConversationWorkingStateCompactionRunner, findConversationWorkingStateMaintenanceBlocker, + inspectConversationWorkingStateStorage, recoverInterruptedConversationWorkingStateCompaction, + type ConversationWorkingStateCompactionWorker, } from './conversation-working-state-compaction'; describe('Conversation Working State compaction engine', () => { @@ -534,3 +538,125 @@ describe('findConversationWorkingStateMaintenanceBlocker', () => { } }); }); + +describe('ConversationWorkingStateCompactionRunner', () => { + class FakeWorker extends EventEmitter implements ConversationWorkingStateCompactionWorker { + unref(): void {} + } + + const request = { + checkpointDatabasePath: '/tmp/deepagents-checkpoints.db', + liveThreadIds: ['conversation-1'], + }; + + it('runs compaction in a Worker and forwards real maintenance phases', async () => { + const worker = new FakeWorker(); + const onPhase = vi.fn(); + const runner = new ConversationWorkingStateCompactionRunner( + () => '/app/compaction-worker.js', + (_workerPath, workerRequest) => { + expect(workerRequest).toEqual(request); + queueMicrotask(() => { + worker.emit('message', { type: 'phase', phase: 'rebuilding' }); + worker.emit('message', { + type: 'result', + result: { physicalBytesBefore: 4096, physicalBytesAfter: 2048 }, + }); + }); + return worker; + } + ); + + await expect(runner.run(request, onPhase)).resolves.toEqual({ + physicalBytesBefore: 4096, + physicalBytesAfter: 2048, + }); + expect(onPhase).toHaveBeenCalledWith('rebuilding'); + }); + + it('preserves a stable Worker failure code', async () => { + const worker = new FakeWorker(); + const runner = new ConversationWorkingStateCompactionRunner( + () => '/app/compaction-worker.js', + () => { + queueMicrotask(() => worker.emit('message', { + type: 'error', + code: 'INSUFFICIENT_DISK_SPACE', + error: 'not enough room', + })); + return worker; + } + ); + + await expect(runner.run(request)).rejects.toMatchObject({ + code: 'INSUFFICIENT_DISK_SPACE', + message: 'not enough room', + }); + }); +}); + +describe('inspectConversationWorkingStateStorage', () => { + let tempDir: string; + let databasePath: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdf-working-state-storage-')); + databasePath = path.join(tempDir, 'deepagents-checkpoints.db'); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it('reports zero usage for a missing database without creating it', () => { + expect(inspectConversationWorkingStateStorage(databasePath)).toEqual({ + physicalBytes: 0, + estimatedReclaimableBytes: 0, + }); + expect(fs.existsSync(databasePath)).toBe(false); + }); + + it('reports valid bounded usage for an empty database', () => { + new Database(databasePath).close(); + + const status = inspectConversationWorkingStateStorage(databasePath); + + expect(status.physicalBytes).toBe(fs.statSync(databasePath).size); + expect(status.estimatedReclaimableBytes).toBeGreaterThanOrEqual(0); + expect(status.estimatedReclaimableBytes).toBeLessThanOrEqual(status.physicalBytes); + }); + + it('accounts for live SQLite sidecars in physical usage', () => { + const db = new Database(databasePath); + db.pragma('journal_mode = WAL'); + db.exec('CREATE TABLE payloads (value BLOB)'); + db.prepare('INSERT INTO payloads VALUES (?)').run(Buffer.alloc(256 * 1024, 1)); + + const expectedPhysicalBytes = [databasePath, `${databasePath}-wal`, `${databasePath}-shm`] + .reduce((total, filePath) => total + (fs.existsSync(filePath) ? fs.statSync(filePath).size : 0), 0); + const status = inspectConversationWorkingStateStorage(databasePath); + + expect(status.physicalBytes).toBe(expectedPhysicalBytes); + expect(status.estimatedReclaimableBytes).toBeLessThanOrEqual(expectedPhysicalBytes); + db.close(); + }); + + it('estimates reclaimable space for a freelist-heavy database without reading payloads', () => { + const db = new Database(databasePath); + db.exec('CREATE TABLE payloads (id INTEGER PRIMARY KEY, value BLOB)'); + const insert = db.prepare('INSERT INTO payloads (value) VALUES (?)'); + const insertMany = db.transaction(() => { + for (let index = 0; index < 24; index += 1) { + insert.run(Buffer.alloc(128 * 1024, index)); + } + }); + insertMany(); + db.exec('DELETE FROM payloads WHERE id <= 20'); + db.close(); + + const status = inspectConversationWorkingStateStorage(databasePath); + + expect(status.estimatedReclaimableBytes).toBeGreaterThan(0); + expect(status.estimatedReclaimableBytes).toBeLessThanOrEqual(status.physicalBytes); + }); +}); diff --git a/src/main/deepagent/conversation-working-state-compaction.ts b/src/main/deepagent/conversation-working-state-compaction.ts index 7cc1a353..b0fb9695 100644 --- a/src/main/deepagent/conversation-working-state-compaction.ts +++ b/src/main/deepagent/conversation-working-state-compaction.ts @@ -12,14 +12,54 @@ import { CONVERSATION_WORKING_STATE_BLOCK_REASONS, CONVERSATION_WORKING_STATE_FAILURE_REASONS, } from '../../shared/conversation-working-state'; +import { Worker } from 'worker_threads'; import { conversationWorkingStateTableExists, reconcileOrphanConversationWorkingState, } from './conversation-working-state-reconciliation'; -import { - getConversationWorkingStatePhysicalBytes, - inspectConversationWorkingStateStorage, -} from './conversation-working-state-storage'; + +export interface ConversationWorkingStateStorageInspection { + physicalBytes: number; + estimatedReclaimableBytes: number; +} + +const SQLITE_STORAGE_SUFFIXES = ['', '-wal', '-shm'] as const; + +export function getConversationWorkingStatePhysicalBytes(databasePath: string): number { + return SQLITE_STORAGE_SUFFIXES.reduce((total, suffix) => { + const filePath = `${databasePath}${suffix}`; + try { + return total + fs.statSync(filePath).size; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return total; + throw error; + } + }, 0); +} + +export function inspectConversationWorkingStateStorage( + databasePath: string +): ConversationWorkingStateStorageInspection { + const physicalBytes = getConversationWorkingStatePhysicalBytes(databasePath); + if (!fs.existsSync(databasePath) || physicalBytes === 0) { + return { physicalBytes, estimatedReclaimableBytes: 0 }; + } + + const db = new Database(databasePath, { readonly: true, fileMustExist: true }); + try { + const pageSize = db.pragma('page_size', { simple: true }) as number; + const pageCount = db.pragma('page_count', { simple: true }) as number; + const freelistCount = db.pragma('freelist_count', { simple: true }) as number; + const estimatedCompactedBytes = Math.max(0, pageCount - freelistCount) * pageSize; + const estimatedReclaimableBytes = Math.max( + 0, + Math.min(physicalBytes, physicalBytes - estimatedCompactedBytes) + ); + return { physicalBytes, estimatedReclaimableBytes }; + } finally { + db.close(); + } +} export interface ConversationWorkingStateCompactionRequest { checkpointDatabasePath: string; @@ -422,3 +462,83 @@ export function compactConversationWorkingStateStorage( if (!rollbackCreated) removeFileFamilyBestEffort(sqliteFileFamily(rollbackPath)); } } + +export type ConversationWorkingStateCompactionWorkerResponse = + | { type: 'phase'; phase: ConversationWorkingStateMaintenancePhase } + | { type: 'result'; result: ConversationWorkingStateCompactionResult } + | { type: 'error'; code: ConversationWorkingStateFailureReason; error: string }; + +export interface ConversationWorkingStateCompactionWorker { + unref(): void; + on(event: 'message', listener: (message: ConversationWorkingStateCompactionWorkerResponse) => void): this; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'exit', listener: (code: number) => void): this; +} + +type CompactionWorkerFactory = ( + workerPath: string, + request: ConversationWorkingStateCompactionRequest +) => ConversationWorkingStateCompactionWorker; + +const createNodeCompactionWorker: CompactionWorkerFactory = (workerPath, request) => + new Worker(workerPath, { workerData: request }); + +export interface ConversationWorkingStateCompactionRunnerContract { + run( + request: ConversationWorkingStateCompactionRequest, + onPhase?: (phase: ConversationWorkingStateMaintenancePhase) => void + ): Promise; +} + +export class ConversationWorkingStateCompactionRunner +implements ConversationWorkingStateCompactionRunnerContract { + constructor( + private readonly resolveWorkerPath: () => string, + private readonly createWorker: CompactionWorkerFactory = createNodeCompactionWorker + ) {} + + run( + request: ConversationWorkingStateCompactionRequest, + onPhase?: (phase: ConversationWorkingStateMaintenancePhase) => void + ): Promise { + return new Promise((resolve, reject) => { + const worker = this.createWorker(this.resolveWorkerPath(), request); + worker.unref(); + let settled = false; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + callback(); + }; + + worker.on('message', (message) => { + if (message.type === 'phase') { + if (!settled) onPhase?.(message.phase); + return; + } + settle(() => { + if (message.type === 'result') { + resolve(message.result); + } else { + reject(new ConversationWorkingStateCompactionError(message.code, message.error)); + } + }); + }); + worker.once('error', (error) => settle(() => reject(error))); + worker.once('exit', (code) => { + settle(() => reject(new Error( + code === 0 + ? 'Conversation Working State compaction Worker exited without a result.' + : `Conversation Working State compaction Worker exited with code ${code}.` + ))); + }); + }); + } +} + +/** Default worker-backed runner: the compaction worker bundle sits beside the main bundle. */ +export function createConversationWorkingStateCompactionRunner(): ConversationWorkingStateCompactionRunnerContract { + return new ConversationWorkingStateCompactionRunner( + () => path.join(__dirname, 'conversation-working-state-compaction-worker.js') + ); +} diff --git a/src/main/deepagent/conversation-working-state-maintenance.ts b/src/main/deepagent/conversation-working-state-maintenance.ts deleted file mode 100644 index bf89a973..00000000 --- a/src/main/deepagent/conversation-working-state-maintenance.ts +++ /dev/null @@ -1,37 +0,0 @@ -import path from 'path'; -import db from '../database'; -import { conversationWorkingStateLifecycle } from './conversation-working-state'; -import { findConversationWorkingStateMaintenanceBlocker } from './conversation-working-state-compaction'; -import { ConversationWorkingStateCompactionRunner } from './conversation-working-state-compaction-runner'; - -const compactionRunner = new ConversationWorkingStateCompactionRunner( - () => path.join(__dirname, 'conversation-working-state-compaction-worker.js') -); - -function readMaintenanceBlocker() { - return findConversationWorkingStateMaintenanceBlocker(db); -} - -function readLiveConversationIds() { - return (db.prepare('SELECT id FROM sessions').all() as Array<{ id: string }>) - .map((session) => session.id); -} - -export function getConversationWorkingStateStorageStatus() { - const status = conversationWorkingStateLifecycle.getStorageStatus(); - if (status.phase === 'analyzing' || status.phase === 'optimizing') { - return status; - } - return { - ...status, - blockedReason: conversationWorkingStateLifecycle.getMaintenanceBlocker(readMaintenanceBlocker), - }; -} - -export function compactConversationWorkingState() { - return conversationWorkingStateLifecycle.compact( - readMaintenanceBlocker, - readLiveConversationIds, - compactionRunner - ); -} diff --git a/src/main/deepagent/conversation-working-state-reconciliation-worker.ts b/src/main/deepagent/conversation-working-state-reconciliation-worker.ts index d6d3bbc1..2d8f97cd 100644 --- a/src/main/deepagent/conversation-working-state-reconciliation-worker.ts +++ b/src/main/deepagent/conversation-working-state-reconciliation-worker.ts @@ -2,8 +2,8 @@ import { parentPort, workerData } from 'worker_threads'; import { reconcileOrphanConversationWorkingState, type ConversationWorkingStateReconciliationRequest, + type ConversationWorkingStateWorkerResponse, } from './conversation-working-state-reconciliation'; -import type { ConversationWorkingStateWorkerResponse } from './conversation-working-state-worker-runner'; if (!parentPort) { throw new Error('Conversation Working State reconciliation requires a Worker parent port.'); diff --git a/src/main/deepagent/conversation-working-state-reconciliation.test.ts b/src/main/deepagent/conversation-working-state-reconciliation.test.ts index db6b3c28..3124a0dd 100644 --- a/src/main/deepagent/conversation-working-state-reconciliation.test.ts +++ b/src/main/deepagent/conversation-working-state-reconciliation.test.ts @@ -1,9 +1,14 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; +import { EventEmitter } from 'events'; import Database from 'better-sqlite3'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { reconcileOrphanConversationWorkingState } from './conversation-working-state-reconciliation'; +import { + ConversationWorkingStateWorkerRunner, + reconcileOrphanConversationWorkingState, + type ConversationWorkingStateWorker, +} from './conversation-working-state-reconciliation'; describe('reconcileOrphanConversationWorkingState', () => { let tempDir: string; @@ -120,3 +125,67 @@ describe('reconcileOrphanConversationWorkingState', () => { reopened.close(); }); }); + +describe('ConversationWorkingStateWorkerRunner', () => { + class FakeWorker extends EventEmitter implements ConversationWorkingStateWorker { + unrefCalled = false; + + unref(): void { + this.unrefCalled = true; + } + } + + const request = { + checkpointDatabasePath: '/tmp/deepagents-checkpoints.db', + liveThreadIds: ['conversation-1'], + }; + + it('passes the reconciliation request to the Worker and returns its result', async () => { + const worker = new FakeWorker(); + let receivedPath = ''; + let receivedRequest: unknown; + const runner = new ConversationWorkingStateWorkerRunner( + () => '/app/reconciliation-worker.js', + (workerPath, workerRequest) => { + receivedPath = workerPath; + receivedRequest = workerRequest; + queueMicrotask(() => worker.emit('message', { + ok: true, + result: { deletedThreadCount: 2 }, + })); + return worker; + } + ); + + await expect(runner.run(request)).resolves.toEqual({ deletedThreadCount: 2 }); + expect(receivedPath).toBe('/app/reconciliation-worker.js'); + expect(receivedRequest).toEqual(request); + expect(worker.unrefCalled).toBe(true); + }); + + it('rejects a structured Worker failure', async () => { + const worker = new FakeWorker(); + const runner = new ConversationWorkingStateWorkerRunner( + () => '/app/reconciliation-worker.js', + () => { + queueMicrotask(() => worker.emit('message', { ok: false, error: 'database busy' })); + return worker; + } + ); + + await expect(runner.run(request)).rejects.toThrow('database busy'); + }); + + it('rejects when the Worker exits before reporting a result', async () => { + const worker = new FakeWorker(); + const runner = new ConversationWorkingStateWorkerRunner( + () => '/app/reconciliation-worker.js', + () => { + queueMicrotask(() => worker.emit('exit', 1)); + return worker; + } + ); + + await expect(runner.run(request)).rejects.toThrow('exited with code 1'); + }); +}); diff --git a/src/main/deepagent/conversation-working-state-reconciliation.ts b/src/main/deepagent/conversation-working-state-reconciliation.ts index 53ce4b03..e2955835 100644 --- a/src/main/deepagent/conversation-working-state-reconciliation.ts +++ b/src/main/deepagent/conversation-working-state-reconciliation.ts @@ -1,4 +1,6 @@ import fs from 'fs'; +import path from 'path'; +import { Worker } from 'worker_threads'; import Database from 'better-sqlite3'; export interface ConversationWorkingStateReconciliationRequest { @@ -63,3 +65,73 @@ export function reconcileOrphanConversationWorkingState( db.close(); } } + +export type ConversationWorkingStateWorkerResponse = + | { ok: true; result: ConversationWorkingStateReconciliationResult } + | { ok: false; error: string }; + +export interface ConversationWorkingStateWorker { + unref(): void; + once(event: 'message', listener: (message: ConversationWorkingStateWorkerResponse) => void): this; + once(event: 'error', listener: (error: Error) => void): this; + once(event: 'exit', listener: (code: number) => void): this; +} + +type WorkerFactory = ( + workerPath: string, + request: ConversationWorkingStateReconciliationRequest +) => ConversationWorkingStateWorker; + +const createNodeWorker: WorkerFactory = (workerPath, request) => + new Worker(workerPath, { workerData: request }); + +export interface ConversationWorkingStateReconciliationRunner { + run( + request: ConversationWorkingStateReconciliationRequest + ): Promise; +} + +export class ConversationWorkingStateWorkerRunner +implements ConversationWorkingStateReconciliationRunner { + constructor( + private readonly resolveWorkerPath: () => string, + private readonly createWorker: WorkerFactory = createNodeWorker + ) {} + + run( + request: ConversationWorkingStateReconciliationRequest + ): Promise { + return new Promise((resolve, reject) => { + const worker = this.createWorker(this.resolveWorkerPath(), request); + worker.unref(); + let settled = false; + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + callback(); + }; + + worker.once('message', (message) => { + settle(() => { + if (message.ok) resolve(message.result); + else reject(new Error(message.error)); + }); + }); + worker.once('error', (error) => settle(() => reject(error))); + worker.once('exit', (code) => { + settle(() => reject(new Error( + code === 0 + ? 'Conversation Working State Worker exited without a result.' + : `Conversation Working State Worker exited with code ${code}.` + ))); + }); + }); + } +} + +/** Default worker-backed runner: the reconciliation worker bundle sits beside the main bundle. */ +export function createConversationWorkingStateReconciliationRunner(): ConversationWorkingStateReconciliationRunner { + return new ConversationWorkingStateWorkerRunner( + () => path.join(__dirname, 'conversation-working-state-reconciliation-worker.js') + ); +} diff --git a/src/main/deepagent/conversation-working-state-storage.test.ts b/src/main/deepagent/conversation-working-state-storage.test.ts deleted file mode 100644 index e0ed2b13..00000000 --- a/src/main/deepagent/conversation-working-state-storage.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import fs from 'fs'; -import os from 'os'; -import path from 'path'; -import Database from 'better-sqlite3'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { inspectConversationWorkingStateStorage } from './conversation-working-state-storage'; - -describe('inspectConversationWorkingStateStorage', () => { - let tempDir: string; - let databasePath: string; - - beforeEach(() => { - tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cdf-working-state-storage-')); - databasePath = path.join(tempDir, 'deepagents-checkpoints.db'); - }); - - afterEach(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('reports zero usage for a missing database without creating it', () => { - expect(inspectConversationWorkingStateStorage(databasePath)).toEqual({ - physicalBytes: 0, - estimatedReclaimableBytes: 0, - }); - expect(fs.existsSync(databasePath)).toBe(false); - }); - - it('reports valid bounded usage for an empty database', () => { - new Database(databasePath).close(); - - const status = inspectConversationWorkingStateStorage(databasePath); - - expect(status.physicalBytes).toBe(fs.statSync(databasePath).size); - expect(status.estimatedReclaimableBytes).toBeGreaterThanOrEqual(0); - expect(status.estimatedReclaimableBytes).toBeLessThanOrEqual(status.physicalBytes); - }); - - it('accounts for live SQLite sidecars in physical usage', () => { - const db = new Database(databasePath); - db.pragma('journal_mode = WAL'); - db.exec('CREATE TABLE payloads (value BLOB)'); - db.prepare('INSERT INTO payloads VALUES (?)').run(Buffer.alloc(256 * 1024, 1)); - - const expectedPhysicalBytes = [databasePath, `${databasePath}-wal`, `${databasePath}-shm`] - .reduce((total, filePath) => total + (fs.existsSync(filePath) ? fs.statSync(filePath).size : 0), 0); - const status = inspectConversationWorkingStateStorage(databasePath); - - expect(status.physicalBytes).toBe(expectedPhysicalBytes); - expect(status.estimatedReclaimableBytes).toBeLessThanOrEqual(expectedPhysicalBytes); - db.close(); - }); - - it('estimates reclaimable space for a freelist-heavy database without reading payloads', () => { - const db = new Database(databasePath); - db.exec('CREATE TABLE payloads (id INTEGER PRIMARY KEY, value BLOB)'); - const insert = db.prepare('INSERT INTO payloads (value) VALUES (?)'); - const insertMany = db.transaction(() => { - for (let index = 0; index < 24; index += 1) { - insert.run(Buffer.alloc(128 * 1024, index)); - } - }); - insertMany(); - db.exec('DELETE FROM payloads WHERE id <= 20'); - db.close(); - - const status = inspectConversationWorkingStateStorage(databasePath); - - expect(status.estimatedReclaimableBytes).toBeGreaterThan(0); - expect(status.estimatedReclaimableBytes).toBeLessThanOrEqual(status.physicalBytes); - }); -}); diff --git a/src/main/deepagent/conversation-working-state-storage.ts b/src/main/deepagent/conversation-working-state-storage.ts deleted file mode 100644 index 84710ba2..00000000 --- a/src/main/deepagent/conversation-working-state-storage.ts +++ /dev/null @@ -1,45 +0,0 @@ -import fs from 'fs'; -import Database from 'better-sqlite3'; - -export interface ConversationWorkingStateStorageInspection { - physicalBytes: number; - estimatedReclaimableBytes: number; -} - -const SQLITE_STORAGE_SUFFIXES = ['', '-wal', '-shm'] as const; - -export function getConversationWorkingStatePhysicalBytes(databasePath: string): number { - return SQLITE_STORAGE_SUFFIXES.reduce((total, suffix) => { - const filePath = `${databasePath}${suffix}`; - try { - return total + fs.statSync(filePath).size; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') return total; - throw error; - } - }, 0); -} - -export function inspectConversationWorkingStateStorage( - databasePath: string -): ConversationWorkingStateStorageInspection { - const physicalBytes = getConversationWorkingStatePhysicalBytes(databasePath); - if (!fs.existsSync(databasePath) || physicalBytes === 0) { - return { physicalBytes, estimatedReclaimableBytes: 0 }; - } - - const db = new Database(databasePath, { readonly: true, fileMustExist: true }); - try { - const pageSize = db.pragma('page_size', { simple: true }) as number; - const pageCount = db.pragma('page_count', { simple: true }) as number; - const freelistCount = db.pragma('freelist_count', { simple: true }) as number; - const estimatedCompactedBytes = Math.max(0, pageCount - freelistCount) * pageSize; - const estimatedReclaimableBytes = Math.max( - 0, - Math.min(physicalBytes, physicalBytes - estimatedCompactedBytes) - ); - return { physicalBytes, estimatedReclaimableBytes }; - } finally { - db.close(); - } -} diff --git a/src/main/deepagent/conversation-working-state-worker-runner.test.ts b/src/main/deepagent/conversation-working-state-worker-runner.test.ts deleted file mode 100644 index f7a664a6..00000000 --- a/src/main/deepagent/conversation-working-state-worker-runner.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { EventEmitter } from 'events'; -import { describe, expect, it } from 'vitest'; -import { - ConversationWorkingStateWorkerRunner, - type ConversationWorkingStateWorker, -} from './conversation-working-state-worker-runner'; - -class FakeWorker extends EventEmitter implements ConversationWorkingStateWorker { - unrefCalled = false; - - unref(): void { - this.unrefCalled = true; - } -} - -const request = { - checkpointDatabasePath: '/tmp/deepagents-checkpoints.db', - liveThreadIds: ['conversation-1'], -}; - -describe('ConversationWorkingStateWorkerRunner', () => { - it('passes the reconciliation request to the Worker and returns its result', async () => { - const worker = new FakeWorker(); - let receivedPath = ''; - let receivedRequest: unknown; - const runner = new ConversationWorkingStateWorkerRunner( - () => '/app/reconciliation-worker.js', - (workerPath, workerRequest) => { - receivedPath = workerPath; - receivedRequest = workerRequest; - queueMicrotask(() => worker.emit('message', { - ok: true, - result: { deletedThreadCount: 2 }, - })); - return worker; - } - ); - - await expect(runner.run(request)).resolves.toEqual({ deletedThreadCount: 2 }); - expect(receivedPath).toBe('/app/reconciliation-worker.js'); - expect(receivedRequest).toEqual(request); - expect(worker.unrefCalled).toBe(true); - }); - - it('rejects a structured Worker failure', async () => { - const worker = new FakeWorker(); - const runner = new ConversationWorkingStateWorkerRunner( - () => '/app/reconciliation-worker.js', - () => { - queueMicrotask(() => worker.emit('message', { ok: false, error: 'database busy' })); - return worker; - } - ); - - await expect(runner.run(request)).rejects.toThrow('database busy'); - }); - - it('rejects when the Worker exits before reporting a result', async () => { - const worker = new FakeWorker(); - const runner = new ConversationWorkingStateWorkerRunner( - () => '/app/reconciliation-worker.js', - () => { - queueMicrotask(() => worker.emit('exit', 1)); - return worker; - } - ); - - await expect(runner.run(request)).rejects.toThrow('exited with code 1'); - }); -}); diff --git a/src/main/deepagent/conversation-working-state-worker-runner.ts b/src/main/deepagent/conversation-working-state-worker-runner.ts deleted file mode 100644 index e9519a6a..00000000 --- a/src/main/deepagent/conversation-working-state-worker-runner.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Worker } from 'worker_threads'; -import type { - ConversationWorkingStateReconciliationRequest, - ConversationWorkingStateReconciliationResult, -} from './conversation-working-state-reconciliation'; - -export type ConversationWorkingStateWorkerResponse = - | { ok: true; result: ConversationWorkingStateReconciliationResult } - | { ok: false; error: string }; - -export interface ConversationWorkingStateWorker { - unref(): void; - once(event: 'message', listener: (message: ConversationWorkingStateWorkerResponse) => void): this; - once(event: 'error', listener: (error: Error) => void): this; - once(event: 'exit', listener: (code: number) => void): this; -} - -type WorkerFactory = ( - workerPath: string, - request: ConversationWorkingStateReconciliationRequest -) => ConversationWorkingStateWorker; - -const createNodeWorker: WorkerFactory = (workerPath, request) => - new Worker(workerPath, { workerData: request }); - -export interface ConversationWorkingStateReconciliationRunner { - run( - request: ConversationWorkingStateReconciliationRequest - ): Promise; -} - -export class ConversationWorkingStateWorkerRunner -implements ConversationWorkingStateReconciliationRunner { - constructor( - private readonly resolveWorkerPath: () => string, - private readonly createWorker: WorkerFactory = createNodeWorker - ) {} - - run( - request: ConversationWorkingStateReconciliationRequest - ): Promise { - return new Promise((resolve, reject) => { - const worker = this.createWorker(this.resolveWorkerPath(), request); - worker.unref(); - let settled = false; - const settle = (callback: () => void) => { - if (settled) return; - settled = true; - callback(); - }; - - worker.once('message', (message) => { - settle(() => { - if (message.ok) resolve(message.result); - else reject(new Error(message.error)); - }); - }); - worker.once('error', (error) => settle(() => reject(error))); - worker.once('exit', (code) => { - settle(() => reject(new Error( - code === 0 - ? 'Conversation Working State Worker exited without a result.' - : `Conversation Working State Worker exited with code ${code}.` - ))); - }); - }); - } -} diff --git a/src/main/deepagent/conversation-working-state.test.ts b/src/main/deepagent/conversation-working-state.test.ts index 86e6c6df..35ca736b 100644 --- a/src/main/deepagent/conversation-working-state.test.ts +++ b/src/main/deepagent/conversation-working-state.test.ts @@ -11,10 +11,10 @@ import { createConversationWorkingStateLifecycle, type ConversationWorkingStateLifecycle, } from './conversation-working-state'; -import type { ConversationWorkingStateCompactionRunnerContract } from './conversation-working-state-compaction-runner'; import { compactConversationWorkingStateStorage, type ConversationWorkingStateCompactionDependencies, + type ConversationWorkingStateCompactionRunnerContract, } from './conversation-working-state-compaction'; function checkpoint(id: string, value: string): Checkpoint { diff --git a/src/main/deepagent/conversation-working-state.ts b/src/main/deepagent/conversation-working-state.ts index e8e3ceba..4d323f84 100644 --- a/src/main/deepagent/conversation-working-state.ts +++ b/src/main/deepagent/conversation-working-state.ts @@ -11,13 +11,13 @@ import { CONVERSATION_WORKING_STATE_BLOCK_REASONS, CONVERSATION_WORKING_STATE_FAILURE_REASONS, } from '../../shared/conversation-working-state'; -import type { ConversationWorkingStateReconciliationRunner } from './conversation-working-state-worker-runner'; -import type { ConversationWorkingStateCompactionRunnerContract } from './conversation-working-state-compaction-runner'; -import { recoverInterruptedConversationWorkingStateCompaction } from './conversation-working-state-compaction'; +import type { ConversationWorkingStateReconciliationRunner } from './conversation-working-state-reconciliation'; import { getConversationWorkingStatePhysicalBytes, inspectConversationWorkingStateStorage, -} from './conversation-working-state-storage'; + recoverInterruptedConversationWorkingStateCompaction, + type ConversationWorkingStateCompactionRunnerContract, +} from './conversation-working-state-compaction'; export const CONVERSATION_WORKING_STATE_MAINTENANCE_LOCKED = 'CONVERSATION_WORKING_STATE_MAINTENANCE_LOCKED' as const; diff --git a/src/main/deepagent/delegated-run-failure.test.ts b/src/main/deepagent/delegated-run-failure.test.ts new file mode 100644 index 00000000..ffed627f --- /dev/null +++ b/src/main/deepagent/delegated-run-failure.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { classifyDelegatedRunFailure } from './delegated-run-failure'; + +describe('classifyDelegatedRunFailure', () => { + it.each([ + ['Request timed out after 60s', 'TIMEOUT'], + ['fetch failed: socket hang up', 'NETWORK'], + ['terminated', 'NETWORK'], + ['429 rate limit exceeded', 'RATE_LIMIT'], + ['permission denied for tool bash', 'PERMISSION_DENIED'], + ['Unauthorized: invalid API key', 'PERMISSION_DENIED'], + ['The operation was aborted', 'INTERRUPTED'], + ['Delegated tool approval is not available for this run', 'DELEGATED_RUNTIME_FAILED'], + ['something unexpected exploded', 'DELEGATED_RUNTIME_FAILED'], + ])('classifies "%s" as %s', (message, code) => { + expect(classifyDelegatedRunFailure(new Error(message))).toEqual({ code, message }); + }); + + it('stringifies non-Error failures and keeps the default code', () => { + expect(classifyDelegatedRunFailure('boom')).toEqual({ + code: 'DELEGATED_RUNTIME_FAILED', + message: 'boom', + }); + }); +}); diff --git a/src/main/deepagent/delegated-runtime-adapter.test.ts b/src/main/deepagent/delegated-runtime-adapter.test.ts new file mode 100644 index 00000000..06545539 --- /dev/null +++ b/src/main/deepagent/delegated-runtime-adapter.test.ts @@ -0,0 +1,333 @@ +import os from 'os'; +import path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('electron', () => ({ + app: { + getPath: vi.fn(() => path.join(os.tmpdir(), 'cdf-delegated-adapter-test-user-data')), + }, +})); + +vi.mock('../database', () => ({ + default: { + prepare: vi.fn(() => ({ + all: vi.fn(() => []), + get: vi.fn(), + run: vi.fn(), + })), + }, +})); + +vi.mock('../store', () => ({ + default: { + get: vi.fn(), + set: vi.fn(), + }, +})); + +vi.mock('./conversation-working-state', () => ({ + DEEPAGENT_CHECKPOINT_NAMESPACE: '', +})); + +vi.mock('./runtime-assembly', () => ({ + assembleDeepAgentRuntime: vi.fn(), + extractPathMentionContext: vi.fn(() => [] as string[]), +})); + +import { + createDelegatedRuntimeAdapter, + type CreateDelegatedRuntimeAdapterOptions, + type DelegatedParentContext, +} from './delegated-runtime-adapter'; +import type { DelegatedRuntimeRequest } from './delegated-agent-run-coordinator'; +import type { DelegatedAgentConfigurationSnapshot } from './delegated-agent-configuration-snapshot'; +import type { CatalogAgent } from '../agent-catalog'; +import type { MCPServer } from '../../shared/types'; + +function catalogAgent(config: Record | null): CatalogAgent { + return { + id: 'agent-1', + role: 'custom', + name: 'Researcher', + slug: 'researcher', + description: null, + provider_id: null, + system_prompt: 'child prompt', + config, + mcpServerExclusionIds: [], + skillNames: [], + created_at: 0, + updated_at: 0, + }; +} + +function snapshotFor( + config: Record | null, + overrides: Partial = {}, +): DelegatedAgentConfigurationSnapshot { + return { + target: catalogAgent(config), + mcpServerExclusionIds: [], + globalSkillPreloadRefs: [], + ...overrides, + }; +} + +function mcpServer(id: string): MCPServer { + return { id, name: id } as unknown as MCPServer; +} + +describe('createDelegatedRuntimeAdapter', () => { + const projectPath = path.join(os.tmpdir(), 'cdf-delegated-adapter-project'); + let parentContext: DelegatedParentContext; + let invokeMock: ReturnType; + let createAgentGraphMock: ReturnType; + let loadMcpToolsMock: ReturnType; + let assembleRuntimeMock: ReturnType; + let resolveInterruptOnMock: ReturnType; + let runDelegatedToolActionMock: ReturnType; + let resolveRunDelegatedToolActionMock: ReturnType; + let options: CreateDelegatedRuntimeAdapterOptions; + + beforeEach(() => { + invokeMock = vi.fn(async () => ({ + structuredResponse: { status: 'success', artifacts: [], summary: 'done' }, + })); + createAgentGraphMock = vi.fn(() => ({ invoke: invokeMock })); + loadMcpToolsMock = vi.fn(async () => ({ client: null, tools: [{ name: 'mcp_search' }] })); + assembleRuntimeMock = vi.fn(async () => ({ + model: { id: 'child-model' }, + provider: { id: 'openai' }, + permissions: [], + skillsRuntime: { skills: [], prompt: '', warnings: [], attributions: [] }, + systemPrompt: 'assembled child prompt', + assemblyWarnings: [], + })); + resolveInterruptOnMock = vi.fn(() => ({ mcp_search: { allowedDecisions: ['approve', 'reject'] } })); + runDelegatedToolActionMock = vi.fn( + async ({ execute }: { execute: () => Promise }) => execute(), + ); + resolveRunDelegatedToolActionMock = vi.fn(() => runDelegatedToolActionMock); + parentContext = { + approvalMode: 'strict', + parentBuiltInToolNames: ['bash', 'fetch'], + parentMcpServerIds: ['mcp-a', 'mcp-b'], + allMcpServers: [mcpServer('mcp-a'), mcpServer('mcp-b')], + skillSnapshot: [], + providerId: 'openai', + parentOverrides: { allowedTools: ['bash'] }, + project: { name: 'Demo', path: projectPath, scene: 'general' }, + agentFileRoots: [projectPath], + sessionId: 'session-1', + } as DelegatedParentContext; + options = { + resolveRunDelegatedToolAction: + resolveRunDelegatedToolActionMock as unknown as + CreateDelegatedRuntimeAdapterOptions['resolveRunDelegatedToolAction'], + createResilienceMiddleware: vi.fn(() => []) as unknown as + CreateDelegatedRuntimeAdapterOptions['createResilienceMiddleware'], + dependencies: { + assembleRuntime: assembleRuntimeMock as unknown as never, + createAgentGraph: createAgentGraphMock as unknown as never, + loadMcpTools: loadMcpToolsMock as unknown as never, + createBuiltInTools: vi.fn(() => [ + { name: 'bash' }, + { name: 'fetch' }, + { name: 'write_file' }, + ]) as unknown as never, + loadRegistryTools: vi.fn(() => []) as unknown as never, + resolveInterruptOn: resolveInterruptOnMock as unknown as never, + }, + }; + }); + + function requestFor(snapshot: DelegatedAgentConfigurationSnapshot | undefined): DelegatedRuntimeRequest { + return { + delegatedRunId: 'delegated-1', + parentAgentRunId: 'run-1', + targetAgentSlug: 'researcher', + goal: 'summarize the repo', + input: { messages: [] }, + configurationSnapshot: snapshot, + } as unknown as DelegatedRuntimeRequest; + } + + it('rejects a request without a configuration snapshot', async () => { + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + + await expect(adapter.run(requestFor(undefined))) + .rejects.toThrow('Delegated target Agent not found: researcher'); + expect(createAgentGraphMock).not.toHaveBeenCalled(); + }); + + it('narrows the child tool scope to the parent baseline (ADR-0062)', async () => { + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + + await adapter.run(requestFor(snapshotFor({ + toolScope: { mode: 'narrow', builtInTools: ['bash', 'write_file'], mcpServerIds: [] }, + }))); + + const graphConfig = createAgentGraphMock.mock.calls[0][0] as { tools: Array<{ name: string }> }; + const toolNames = graphConfig.tools.map((tool) => tool.name); + // write_file 不在父基线内、fetch 未被选中——子集合只能收窄不能扩大。 + expect(toolNames).toContain('bash'); + expect(toolNames).not.toContain('write_file'); + expect(toolNames).not.toContain('fetch'); + expect(loadMcpToolsMock).toHaveBeenCalledWith('agent-1', [], parentContext.allMcpServers); + }); + + it('filters snapshot-excluded MCP servers from the inherited child scope', async () => { + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + + await adapter.run(requestFor(snapshotFor(null, { mcpServerExclusionIds: ['mcp-b'] }))); + + const [, childServers] = loadMcpToolsMock.mock.calls[0] as [string, MCPServer[], MCPServer[]]; + expect(childServers.map((server) => server.id)).toEqual(['mcp-a']); + }); + + it('propagates the Conversation Skill Snapshot and preload refs into child assembly', async () => { + const skillSnapshot = [{ name: 'paper-search' }] as unknown as + DelegatedParentContext['skillSnapshot']; + const adapter = createDelegatedRuntimeAdapter({ ...parentContext, skillSnapshot }, options); + + await adapter.run(requestFor(snapshotFor(null, { + globalSkillPreloadRefs: ['built-in:paper-search'], + }))); + + const [target, providerId, project, skillNames, , , , passedSnapshot] = + assembleRuntimeMock.mock.calls[0]; + expect(target.id).toBe('agent-1'); + expect(providerId).toBe('openai'); + expect(project.path).toBe(projectPath); + expect(skillNames).toEqual(['built-in:paper-search']); + expect(passedSnapshot).toBe(skillSnapshot); + }); + + it('builds an isolated graph per run and resolves the tool-action callback lazily (ADR-0061)', async () => { + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + expect(resolveRunDelegatedToolActionMock).not.toHaveBeenCalled(); + + await adapter.run(requestFor(snapshotFor(null))); + await adapter.run(requestFor(snapshotFor(null))); + + expect(createAgentGraphMock).toHaveBeenCalledTimes(2); + const [firstConfig, secondConfig] = createAgentGraphMock.mock.calls.map( + (call) => call[0] as { checkpointer: unknown; backend: unknown }, + ); + expect(firstConfig.checkpointer).not.toBe(secondConfig.checkpointer); + expect(firstConfig.backend).not.toBe(secondConfig.backend); + expect(resolveRunDelegatedToolActionMock).toHaveBeenCalledTimes(2); + }); + + it('routes gated and ungated tool calls through the same narrow callback', async () => { + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + await adapter.run(requestFor(snapshotFor(null))); + const graphConfig = createAgentGraphMock.mock.calls[0][0] as { + middleware: Array<{ + wrapToolCall: ( + request: unknown, + handler: (request: unknown) => Promise, + ) => Promise; + }>; + }; + const approvalMiddleware = graphConfig.middleware[0]; + const handler = vi.fn(async () => 'handled'); + + await expect(approvalMiddleware.wrapToolCall( + { toolCall: { id: 'gated-1', name: 'mcp_search', args: { query: 'cdf' } } }, + handler, + )).resolves.toBe('handled'); + await expect(approvalMiddleware.wrapToolCall( + { toolCall: { id: 'open-1', name: 'bash', args: { command: 'pwd' } } }, + handler, + )).resolves.toBe('handled'); + + expect(runDelegatedToolActionMock).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + delegatedRunId: 'delegated-1', + action: { id: 'gated-1', name: 'mcp_search', args: { query: 'cdf' } }, + requiresApproval: true, + }), + ); + expect(runDelegatedToolActionMock).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + delegatedRunId: 'delegated-1', + action: { id: 'open-1', name: 'bash', args: { command: 'pwd' } }, + requiresApproval: false, + }), + ); + }); + + it('propagates a rejected tool-action callback without invoking the tool handler', async () => { + runDelegatedToolActionMock.mockRejectedValueOnce(new Error('approval gateway failed')); + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + await adapter.run(requestFor(snapshotFor(null))); + const graphConfig = createAgentGraphMock.mock.calls[0][0] as { + middleware: Array<{ + wrapToolCall: ( + request: unknown, + handler: (request: unknown) => Promise, + ) => Promise; + }>; + }; + const handler = vi.fn(async () => 'handled'); + + await expect(graphConfig.middleware[0].wrapToolCall( + { toolCall: { id: 'gated-1', name: 'mcp_search', args: {} } }, + handler, + )).rejects.toThrow('approval gateway failed'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('returns the structured child result when it matches the delegated contract', async () => { + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + + await expect(adapter.run(requestFor(snapshotFor(null)))).resolves.toEqual({ + status: 'success', + artifacts: [], + summary: 'done', + }); + }); + + it('falls back to a truncated last-message summary when structured output is invalid', async () => { + invokeMock.mockResolvedValueOnce({ + structuredResponse: { nope: true }, + messages: [{ content: 'x'.repeat(3000) }], + }); + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + + const result = await adapter.run(requestFor(snapshotFor(null))); + + expect(result.status).toBe('success'); + expect(result.summary).toHaveLength(2000); + }); + + it('surfaces unresolved child interrupts as a failure instead of hanging approval', async () => { + invokeMock.mockResolvedValueOnce({ + __interrupt__: [{ value: 'approval requested' }], + }); + const adapter = createDelegatedRuntimeAdapter(parentContext, options); + + await expect(adapter.run(requestFor(snapshotFor(null)))) + .rejects.toThrow('Delegated tool approval is not available for this run'); + }); + + it.each(['strict', 'agent_decides', 'bypass'] as const)( + 'propagates the parent approval mode into the child approval gate (ADR-0063: %s)', + async (approvalMode) => { + const adapter = createDelegatedRuntimeAdapter({ ...parentContext, approvalMode }, options); + + await adapter.run(requestFor(snapshotFor(null))); + + // The adapter must derive the child gate from the parent's approval mode, + // not from a hardcoded default. ADR-0063: child inherits one mode. + expect(resolveInterruptOnMock).toHaveBeenCalledTimes(1); + expect(resolveInterruptOnMock).toHaveBeenCalledWith( + approvalMode, + expect.arrayContaining(['mcp_search']), + ); + }, + ); +}); diff --git a/src/main/deepagent/delegated-runtime-adapter.ts b/src/main/deepagent/delegated-runtime-adapter.ts new file mode 100644 index 00000000..80c9666e --- /dev/null +++ b/src/main/deepagent/delegated-runtime-adapter.ts @@ -0,0 +1,348 @@ +import crypto from 'crypto'; +import type { ToolMessage } from '@langchain/core/messages'; +import { MemorySaver } from '@langchain/langgraph'; +import { createMiddleware } from 'langchain'; +import { createDeepAgent, CompositeBackend, StateBackend } from 'deepagents'; +import log from '../logger'; +import { assembleDeepAgentRuntime, extractPathMentionContext } from './runtime-assembly'; +import { + createBuiltInTools, + getRuntimeToolNames, + loadMcpTools, + loadRegistryTools, + resolveInterruptOn, +} from './shared-infra'; +import { readAgentToolScope, selectDelegatedToolScope } from './agent-tool-scope'; +import { resolveDelegatedModelOverrides } from './delegated-model-selection'; +import { ProjectConfinedFilesystemBackend } from './project-confined-backend'; +import { DEEPAGENT_CHECKPOINT_NAMESPACE } from './conversation-working-state'; +import { subagentStepStorage } from './subagent-step-storage'; +import type { + DelegatedRuntimeAdapter, + DelegatedRuntimeRequest, +} from './delegated-agent-run-coordinator'; +import type { DelegatedToolActionInput } from './delegated-tool-approval-scheduler'; +import { + DELEGATED_TASK_RESULT_SCHEMA, + type ApprovalMode, + type ChatRuntimeOverrides, + type DelegatedTaskResult, + type MCPServer, + type ProjectScene, +} from '../../shared/types'; +import type { ConversationSkillSnapshotEntry } from '../../shared/skills'; + +/** + * Delegated Agent Run 的隔离运行时构造(ADR-0061 实现本体)。 + * + * 父运行向子运行的继承契约收敛为 DelegatedParentContext 的显式字段: + * 审批模式(ADR-0063)、工具 scope 收窄基线(ADR-0062)、Conversation + * Skill Snapshot 传播、模型选择输入与文件系统限域。coordinator 持有本 + * adapter,而 adapter 仅在执行期经延迟解析取回审批门控入口——原先靠 + * 装配闭包捕获形成的双向绑定改为显式注入。 + */ + +export interface DelegatedParentContext { + /** ADR-0063: Delegated Runs inherit the one Conversation approval mode. */ + approvalMode: ApprovalMode; + /** ADR-0062 收窄基线:父运行的内建工具名集合。 */ + parentBuiltInToolNames: string[]; + /** ADR-0062 收窄基线:父运行可见的 MCP server 标识。 */ + parentMcpServerIds: string[]; + /** 全量已连接 MCP 目录;子候选集 = 全量 - 配置快照排除项。 */ + allMcpServers: MCPServer[]; + /** Conversation Skill Snapshot:子运行 Skill 预载只能从中选择。 */ + skillSnapshot: readonly ConversationSkillSnapshotEntry[] | null; + /** 父 provider 标识,作为委派模型选择的回退输入。 */ + providerId: string; + /** 父模型 overrides(含 allowedTools 韧性约束)。 */ + parentOverrides: ChatRuntimeOverrides | undefined; + project: { name: string; path: string; scene?: ProjectScene }; + /** 文件系统限域根(含 Project 根与既定附加根)。 */ + agentFileRoots: string[]; + sessionId: string; +} + +type DeepAgentMiddleware = NonNullable< + NonNullable[0]>['middleware'] +>; + +export type DelegatedResilienceMiddlewareFactory = ( + ...allowedToolSets: Array +) => DeepAgentMiddleware; + +export type RunDelegatedToolAction = ( + input: DelegatedToolActionInput, +) => Promise; + +export interface DelegatedRuntimeExecutionDependencies { + assembleRuntime: typeof assembleDeepAgentRuntime; + createAgentGraph: typeof createDeepAgent; + loadMcpTools: typeof loadMcpTools; + createBuiltInTools: typeof createBuiltInTools; + loadRegistryTools: typeof loadRegistryTools; + /** ADR-0063: 解析子运行审批门控集合;生产默认用 shared-infra 真实现。 */ + resolveInterruptOn: typeof resolveInterruptOn; +} + +export interface CreateDelegatedRuntimeAdapterOptions { + /** + * 审批门控窄能力的延迟解析:coordinator 构造时持有 adapter,adapter 仅在 + * run() 执行期取回执行单次 tool action 的 callback,不感知 coordinator。 + */ + resolveRunDelegatedToolAction: () => RunDelegatedToolAction; + /** 子运行韧性中间件工厂(工具/模型重试与失败观察归属装配层)。 */ + createResilienceMiddleware: DelegatedResilienceMiddlewareFactory; + dependencies?: Partial; +} + +function createDelegatedToolApprovalMiddleware( + runDelegatedToolAction: RunDelegatedToolAction, + delegatedRunId: string, + gatedToolNames: Set, +) { + return createMiddleware({ + name: 'DelegatedToolApprovalMiddleware', + wrapToolCall: async (request, handler) => { + const runtimeTool = request as { tool?: { name?: string } }; + const toolName = request.toolCall?.name || runtimeTool.tool?.name || 'unknown'; + const actionId = request.toolCall?.id || crypto.randomUUID(); + return runDelegatedToolAction({ + delegatedRunId, + action: { + id: actionId, + name: toolName, + args: (request.toolCall as { args?: unknown })?.args, + }, + requiresApproval: gatedToolNames.has(toolName), + execute: async () => handler(request), + }); + }, + }); +} + +function createDelegatedProgressCallbacks(request: DelegatedRuntimeRequest) { + const onStep = request.onStep; + if (!onStep) return undefined; + + let tokenBuffer: string[] = []; + const emitText = (text: string) => { + if (!text) return; + onStep({ + type: 'text_chunk', + ts: Date.now(), + content: text, + delegatedRunId: request.delegatedRunId, + }); + }; + + return [{ + handleLLMStart() { + tokenBuffer = []; + }, + handleLLMNewToken(token: string) { + if (token) tokenBuffer.push(token); + }, + handleLLMEnd(output: unknown) { + const value = output as { + generations?: Array>; + }; + const generation = value.generations?.[0]?.[0]; + const toolCalls = generation?.message?.additional_kwargs?.tool_calls + ?? generation?.message?.tool_calls; + const content = generation?.message?.content; + const hasToolCalls = (Array.isArray(toolCalls) && toolCalls.length > 0) + || (Array.isArray(content) && content.some((part) => ( + !!part && typeof part === 'object' && (part as { type?: unknown }).type === 'tool_use' + ))); + if (hasToolCalls) { + tokenBuffer = []; + return; + } + if (tokenBuffer.length > 0) { + for (const token of tokenBuffer) emitText(token); + tokenBuffer = []; + return; + } + if (typeof content === 'string') { + emitText(content); + } else if (Array.isArray(content)) { + for (const part of content) { + if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') { + const text = (part as { text?: unknown }).text; + if (typeof text === 'string') emitText(text); + } + } + } else if (typeof generation?.text === 'string') { + emitText(generation.text); + } + }, + }]; +} + +export function createDelegatedRuntimeAdapter( + parentContext: DelegatedParentContext, + options: CreateDelegatedRuntimeAdapterOptions, +): DelegatedRuntimeAdapter { + const { + approvalMode, + parentBuiltInToolNames, + parentMcpServerIds, + allMcpServers, + skillSnapshot, + providerId, + parentOverrides, + project, + agentFileRoots, + sessionId, + } = parentContext; + const deps: DelegatedRuntimeExecutionDependencies = { + assembleRuntime: assembleDeepAgentRuntime, + createAgentGraph: createDeepAgent, + loadMcpTools, + createBuiltInTools, + loadRegistryTools, + resolveInterruptOn, + ...options.dependencies, + }; + + return { + run: async (request) => { + const snapshot = request.configurationSnapshot; + if (!snapshot) { + throw new Error(`Delegated target Agent not found: ${request.targetAgentSlug}`); + } + const target = snapshot.target; + + // Every Delegated Agent Run owns fresh mutable execution state. Agent + // configuration is reused, but model/graph/backend/checkpoint/tools are not. + const childBackend = new CompositeBackend(new StateBackend(), { + "/": new ProjectConfinedFilesystemBackend({ + rootDir: "/", + virtualMode: false, + allowedRoots: agentFileRoots, + projectRoot: project.path, + }), + }); + const childBuiltInTools = deps.createBuiltInTools(project.path, sessionId); + try { + childBuiltInTools.push(...deps.loadRegistryTools()); + } catch (error) { + log.warn('[runtime] Failed to load delegated built-in tools from registry:', error); + } + const targetToolScope = readAgentToolScope(target.config); + const childScope = selectDelegatedToolScope({ + agentConfig: target.config, + parentBuiltInToolNames, + childBuiltInTools, + parentMcpServerIds, + childMcpServers: allMcpServers.filter( + (server) => !snapshot.mcpServerExclusionIds.includes(server.id), + ), + }); + const childMcpRuntime = await deps.loadMcpTools(target.id, childScope.mcpServers, allMcpServers); + const childSkillNames = snapshot.globalSkillPreloadRefs; + const childToolNames = getRuntimeToolNames([ + ...childMcpRuntime.tools, + ...childScope.builtInTools, + ]); + const childOverrides = resolveDelegatedModelOverrides({ + targetProviderId: target.provider_id, + targetConfig: target.config, + parentProviderId: providerId, + parentOverrides, + }); + const childAssembly = await deps.assembleRuntime( + target, + providerId, + project, + childSkillNames, + extractPathMentionContext(request.goal), + childToolNames, + childOverrides, + skillSnapshot, + ); + for (const warning of childAssembly.assemblyWarnings) { + log.warn('[runtime] Ignored invalid delegated Agent Skill runtime input:', warning); + } + + const childInterruptOn = deps.resolveInterruptOn( + approvalMode, + getRuntimeToolNames(childMcpRuntime.tools), + ); + const gatedToolNames = new Set(Object.keys(childInterruptOn)); + const childAgent = deps.createAgentGraph({ + model: childAssembly.model, + backend: childBackend, + systemPrompt: childAssembly.systemPrompt || undefined, + permissions: childAssembly.permissions, + tools: [...childMcpRuntime.tools, ...childScope.builtInTools], + middleware: [ + createDelegatedToolApprovalMiddleware( + options.resolveRunDelegatedToolAction(), + request.delegatedRunId, + gatedToolNames, + ), + ...options.createResilienceMiddleware( + parentOverrides?.allowedTools, + targetToolScope.mode === 'narrow' + ? [ + ...(targetToolScope.builtInTools ?? []), + ...getRuntimeToolNames(childMcpRuntime.tools), + ] + : undefined, + ), + ], + responseFormat: DELEGATED_TASK_RESULT_SCHEMA as unknown as NonNullable< + NonNullable[0]>['responseFormat'] + >, + checkpointer: new MemorySaver(), + }); + const progressCallbacks = createDelegatedProgressCallbacks(request); + const invokeChild = () => childAgent.invoke( + request.input as Parameters[0], + { + signal: request.signal, + callbacks: progressCallbacks, + configurable: { + thread_id: request.delegatedRunId, + checkpoint_ns: DEEPAGENT_CHECKPOINT_NAMESPACE, + delegatedRunId: request.delegatedRunId, + }, + }, + ); + const childResult = await (request.onStep + ? subagentStepStorage.run({ onStep: request.onStep }, invokeChild) + : invokeChild()) as unknown as { + structuredResponse?: unknown; + messages?: Array<{ content?: unknown }>; + __interrupt__?: unknown; + interrupts?: unknown; + }; + const childInterrupts = childResult.__interrupt__ ?? childResult.interrupts; + if (Array.isArray(childInterrupts) && childInterrupts.length > 0) { + throw new Error('Delegated tool approval is not available for this run'); + } + const structured = DELEGATED_TASK_RESULT_SCHEMA.safeParse(childResult?.structuredResponse); + if (structured.success) return structured.data; + + const messages = Array.isArray(childResult?.messages) ? childResult.messages : []; + const lastMessage = messages[messages.length - 1]; + const content = typeof lastMessage?.content === 'string' + ? lastMessage.content + : JSON.stringify(lastMessage?.content ?? 'Task completed'); + return { + status: 'success', + artifacts: [], + summary: content.slice(0, 2_000), + } satisfies DelegatedTaskResult; + }, + }; +} diff --git a/src/main/deepagent/runtime-assembly.ts b/src/main/deepagent/runtime-assembly.ts index 635b9e28..903dd1f8 100644 --- a/src/main/deepagent/runtime-assembly.ts +++ b/src/main/deepagent/runtime-assembly.ts @@ -15,11 +15,9 @@ import { import { createLangChainModel, type RuntimeProviderModelConfig } from './llm-adapter'; import { prepareAISubscriptionRuntimeModel } from '../ai-subscription-runtime'; import { - getBuiltInSkillDirs, - getScopePath, + buildProjectSkillsRuntime, resolveConversationSkillSnapshotConfig, -} from './skill-manager'; -import { buildCdfSkillsRuntime } from './skills-runtime/cdf-skills-runtime'; +} from './skill-catalog'; import { globalSkillReferenceToKey, skillReferencesToPreloadNames, @@ -30,7 +28,7 @@ import type { ConversationSkillSnapshotEntry } from '../../shared/skills'; import { buildProjectContext } from './project-context'; export { buildProjectContext } from './project-context'; -import type { ResolvedSkillCatalogEntry } from './skills-runtime/skill-sources'; +import type { ResolvedSkillCatalogEntry } from './skill-catalog'; // ============================================================================= // Provider 模型配置解析 @@ -262,7 +260,7 @@ export function buildCdfCapabilityToolsPrompt(toolNames: string[]): string { interface CdfSkillsAssemblyResult { permissions: FilesystemPermission[]; - skillsRuntime: ReturnType; + skillsRuntime: ReturnType; warnings: string[]; } @@ -281,9 +279,7 @@ export function buildCdfSkillsRuntimeAssembly( sceneId: ProjectScene = 'general', skillSnapshot?: readonly ConversationSkillSnapshotEntry[] | null, ): CdfSkillsAssemblyResult { - const skillsRuntime = buildCdfSkillsRuntime(projectPath, { - builtInSkillDirs: getBuiltInSkillDirs(), - userSkillsDir: getScopePath(projectPath, 'global'), + const skillsRuntime = buildProjectSkillsRuntime(projectPath, { preloadSkillKeys: getPreloadSkillKeys(skillNames), pathContext, sceneId, @@ -307,7 +303,7 @@ export interface DeepAgentAssemblyResult { provider: ProviderRow; permissions: FilesystemPermission[]; systemPrompt: string; - skillsRuntime: ReturnType; + skillsRuntime: ReturnType; assemblyWarnings: string[]; } diff --git a/src/main/deepagent/runtime.ts b/src/main/deepagent/runtime.ts index 524e74a8..07af10c8 100644 --- a/src/main/deepagent/runtime.ts +++ b/src/main/deepagent/runtime.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import type { SqliteSaver } from '@langchain/langgraph-checkpoint-sqlite'; -import { isGraphInterrupt, MemorySaver } from '@langchain/langgraph'; +import { isGraphInterrupt } from '@langchain/langgraph'; import { createMiddleware, modelRetryMiddleware, ToolMessage, toolRetryMiddleware } from 'langchain'; import db from '../database'; import log from '../logger'; @@ -35,16 +35,14 @@ import { DelegatedAgentRunRepository } from './delegated-agent-run-repository'; import { DelegatedAgentRunCoordinator, type DelegatedRuntimeAdapter, - type DelegatedRuntimeRequest, } from './delegated-agent-run-coordinator'; import type { DelegatedAgentRun, DelegatedTaskResult } from '../../shared/types'; import { createDelegatedSubagentAdapter } from './delegated-subagent-adapter'; +import { createDelegatedRuntimeAdapter } from './delegated-runtime-adapter'; import { captureDelegatedAgentConfigurationSnapshot, type DelegatedAgentConfigurationSnapshot, } from './delegated-agent-configuration-snapshot'; -import { readAgentToolScope, selectDelegatedToolScope } from './agent-tool-scope'; -import { resolveDelegatedModelOverrides } from './delegated-model-selection'; import { conversationWorkingStateLifecycle, DEEPAGENT_CHECKPOINT_NAMESPACE } from './conversation-working-state'; import { ProjectConfinedFilesystemBackend, computeAgentFileRoots } from './project-confined-backend'; import { getOrCaptureConversationSystemContextSnapshot } from '../conversation-system-context-snapshot'; @@ -62,7 +60,7 @@ const WORKFLOW_RUN_PROMPT = ` - 每完成一个阶段并对照验收标准自检通过后,必须调用 advance_stage 工具提交结构化验收报告(逐条自评 + 产物清单 + 总结);这会触发阶段门禁并推进到下一阶段。不要只用文字宣布"完成"就停下——不调用 advance_stage 工作流不会前进。 - 阶段内先用 create_task 一次性规划任务图并用 set_task_dependencies 标注依赖,再用 parallel_tasks 派子 Agent 执行,用 update_task_status / list_tasks 跟踪进度。 - 阶段游标由主进程在门禁通过后权威推进,你无需自行编号或跳跃阶段。`; -import { DELEGATED_TASK_RESULT_SCHEMA, type ApprovalMode, type ChatRuntimeOverrides } from '../../shared/types'; +import { type ApprovalMode, type ChatRuntimeOverrides } from '../../shared/types'; import { getCurrentStreamAccumulator } from './stream-accumulator'; import { subagentStepStorage } from './subagent-step-storage'; @@ -396,98 +394,6 @@ export function createSubagentResilienceMiddleware(...allowedToolSets: Array, -) { - return createMiddleware({ - name: 'DelegatedToolApprovalMiddleware', - wrapToolCall: async (request, handler) => { - const runtimeTool = request as { tool?: { name?: string } }; - const toolName = request.toolCall?.name || runtimeTool.tool?.name || 'unknown'; - const actionId = request.toolCall?.id || crypto.randomUUID(); - return coordinator.runToolAction({ - delegatedRunId, - action: { - id: actionId, - name: toolName, - args: (request.toolCall as { args?: unknown })?.args, - }, - requiresApproval: gatedToolNames.has(toolName), - execute: async () => handler(request), - }); - }, - }); -} - - -function createDelegatedProgressCallbacks(request: DelegatedRuntimeRequest) { - const onStep = request.onStep; - if (!onStep) return undefined; - - let tokenBuffer: string[] = []; - const emitText = (text: string) => { - if (!text) return; - onStep({ - type: 'text_chunk', - ts: Date.now(), - content: text, - delegatedRunId: request.delegatedRunId, - }); - }; - - return [{ - handleLLMStart() { - tokenBuffer = []; - }, - handleLLMNewToken(token: string) { - if (token) tokenBuffer.push(token); - }, - handleLLMEnd(output: unknown) { - const value = output as { - generations?: Array>; - }; - const generation = value.generations?.[0]?.[0]; - const toolCalls = generation?.message?.additional_kwargs?.tool_calls - ?? generation?.message?.tool_calls; - const content = generation?.message?.content; - const hasToolCalls = (Array.isArray(toolCalls) && toolCalls.length > 0) - || (Array.isArray(content) && content.some((part) => ( - !!part && typeof part === 'object' && (part as { type?: unknown }).type === 'tool_use' - ))); - if (hasToolCalls) { - tokenBuffer = []; - return; - } - if (tokenBuffer.length > 0) { - for (const token of tokenBuffer) emitText(token); - tokenBuffer = []; - return; - } - if (typeof content === 'string') { - emitText(content); - } else if (Array.isArray(content)) { - for (const part of content) { - if (part && typeof part === 'object' && (part as { type?: unknown }).type === 'text') { - const text = (part as { text?: unknown }).text; - if (typeof text === 'string') emitText(text); - } - } - } else if (typeof generation?.text === 'string') { - emitText(generation.text); - } - }, - }]; -} - export async function createDeepAgentRuntime( projectId: string, sessionId: string, @@ -625,138 +531,28 @@ async function buildDeepAgentRuntime( const delegatedTargets = new Map(); const delegatedRunRepository = new DelegatedAgentRunRepository(db); - const delegatedRuntimeAdapter: DelegatedRuntimeAdapter = { - run: async (request) => { - const snapshot = request.configurationSnapshot; - if (!snapshot) { - throw new Error(`Delegated target Agent not found: ${request.targetAgentSlug}`); - } - const target = snapshot.target; - - // Every Delegated Agent Run owns fresh mutable execution state. Agent - // configuration is reused, but model/graph/backend/checkpoint/tools are not. - const childBackend = new CompositeBackend(new StateBackend(), { - "/": new ProjectConfinedFilesystemBackend({ - rootDir: "/", - virtualMode: false, - allowedRoots: agentFileRoots, - projectRoot: project.path, - }), - }); - const childBuiltInTools = createBuiltInTools(project.path, sessionId); - try { - childBuiltInTools.push(...loadRegistryTools()); - } catch (error) { - log.warn('[runtime] Failed to load delegated built-in tools from registry:', error); - } - const targetToolScope = readAgentToolScope(target.config); - const childScope = selectDelegatedToolScope({ - agentConfig: target.config, - parentBuiltInToolNames: builtInToolNames, - childBuiltInTools, - parentMcpServerIds: mcpServers.map((server) => server.id), - childMcpServers: allMcpServers.filter( - (server) => !snapshot.mcpServerExclusionIds.includes(server.id), - ), - }); - const childMcpRuntime = await loadMcpTools(target.id, childScope.mcpServers, allMcpServers); - const childSkillNames = snapshot.globalSkillPreloadRefs; - const childToolNames = getRuntimeToolNames([ - ...childMcpRuntime.tools, - ...childScope.builtInTools, - ]); - const childOverrides = resolveDelegatedModelOverrides({ - targetProviderId: target.provider_id, - targetConfig: target.config, - parentProviderId: provider.id, - parentOverrides: overrides, - }); - const childAssembly = await assembleDeepAgentRuntime( - target, - provider.id, - project, - childSkillNames, - extractPathMentionContext(request.goal), - childToolNames, - childOverrides, - skillSnapshot, - ); - for (const warning of childAssembly.assemblyWarnings) { - log.warn('[runtime] Ignored invalid delegated Agent Skill runtime input:', warning); - } - - const childInterruptOn = resolveInterruptOn( - currentApprovalMode, - getRuntimeToolNames(childMcpRuntime.tools), - ); - const gatedToolNames = new Set(Object.keys(childInterruptOn)); - const childAgent = createDeepAgent({ - model: childAssembly.model, - backend: childBackend, - systemPrompt: childAssembly.systemPrompt || undefined, - permissions: childAssembly.permissions, - tools: [...childMcpRuntime.tools, ...childScope.builtInTools], - middleware: [ - createDelegatedToolApprovalMiddleware( - delegatedRunCoordinator, - request.delegatedRunId, - gatedToolNames, - ), - ...createSubagentResilienceMiddleware( - overrides?.allowedTools, - targetToolScope.mode === 'narrow' - ? [ - ...(targetToolScope.builtInTools ?? []), - ...getRuntimeToolNames(childMcpRuntime.tools), - ] - : undefined, - ), - ], - responseFormat: DELEGATED_TASK_RESULT_SCHEMA as unknown as NonNullable< - NonNullable[0]>['responseFormat'] - >, - checkpointer: new MemorySaver(), - }); - const progressCallbacks = createDelegatedProgressCallbacks(request); - const invokeChild = () => childAgent.invoke( - request.input as Parameters[0], - { - signal: request.signal, - callbacks: progressCallbacks, - configurable: { - thread_id: request.delegatedRunId, - checkpoint_ns: DEEPAGENT_CHECKPOINT_NAMESPACE, - delegatedRunId: request.delegatedRunId, - }, - }, - ); - const childResult = await (request.onStep - ? subagentStepStorage.run({ onStep: request.onStep }, invokeChild) - : invokeChild()) as unknown as { - structuredResponse?: unknown; - messages?: Array<{ content?: unknown }>; - __interrupt__?: unknown; - interrupts?: unknown; - }; - const childInterrupts = childResult.__interrupt__ ?? childResult.interrupts; - if (Array.isArray(childInterrupts) && childInterrupts.length > 0) { - throw new Error('Delegated tool approval is not available for this run'); - } - const structured = DELEGATED_TASK_RESULT_SCHEMA.safeParse(childResult?.structuredResponse); - if (structured.success) return structured.data; - - const messages = Array.isArray(childResult?.messages) ? childResult.messages : []; - const lastMessage = messages[messages.length - 1]; - const content = typeof lastMessage?.content === 'string' - ? lastMessage.content - : JSON.stringify(lastMessage?.content ?? 'Task completed'); - return { - status: 'success', - artifacts: [], - summary: content.slice(0, 2_000), - } satisfies DelegatedTaskResult; + // ADR-0061/0062/0063:隔离运行时构造收敛在 delegated-runtime-adapter, + // 父→子继承契约是显式的窄接口;coordinator 经延迟解析只注入单次审批执行 callback。 + const delegatedRuntimeAdapter: DelegatedRuntimeAdapter = createDelegatedRuntimeAdapter( + { + approvalMode: currentApprovalMode, + parentBuiltInToolNames: builtInToolNames, + parentMcpServerIds: mcpServers.map((server) => server.id), + allMcpServers, + skillSnapshot, + providerId: provider.id, + parentOverrides: overrides, + project, + agentFileRoots, + sessionId, }, - }; + { + resolveRunDelegatedToolAction: () => ( + (input) => delegatedRunCoordinator.runToolAction(input) + ), + createResilienceMiddleware: createSubagentResilienceMiddleware, + }, + ); const delegatedRunCoordinator = new DelegatedAgentRunCoordinator( delegatedRunRepository, delegatedRuntimeAdapter, diff --git a/src/main/deepagent/skill-catalog.test.ts b/src/main/deepagent/skill-catalog.test.ts new file mode 100644 index 00000000..24dce6fb --- /dev/null +++ b/src/main/deepagent/skill-catalog.test.ts @@ -0,0 +1,70 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + captureConversationSkillSnapshot, + isGlobalSkillSourceKind, + resolveProjectSkillCatalog, +} from './skill-catalog'; + +const cleanupPaths: string[] = []; + +afterEach(() => { + for (const target of cleanupPaths.splice(0)) { + fs.rmSync(target, { recursive: true, force: true }); + } +}); + +function createProjectWithSkill(name: string): string { + const projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'cdf-skill-catalog-')); + cleanupPaths.push(projectPath); + const skillDir = path.join(projectPath, '.cdf', 'skills', name); + fs.mkdirSync(skillDir, { recursive: true }); + fs.writeFileSync( + path.join(skillDir, 'SKILL.md'), + `---\nname: ${name}\ndescription: Project-owned skill\n---\n\nInstructions\n`, + ); + return projectPath; +} + +describe('Skill Catalog', () => { + it('never routes Project Skills through the Scene exposure predicate', () => { + const projectPath = createProjectWithSkill('project-only-skill'); + + const catalog = resolveProjectSkillCatalog(projectPath, { + isGlobalSkillExposed: () => false, + }); + + expect(catalog.skills.map((skill) => skill.name)).toContain('project-only-skill'); + expect(catalog.skills.some((skill) => isGlobalSkillSourceKind(skill.sourceKind))).toBe(false); + }); + + it('applies both the includeSkill filter and the Scene exposure predicate', () => { + const projectPath = createProjectWithSkill('filtered-project-skill'); + + const catalog = resolveProjectSkillCatalog(projectPath, { + includeSkill: (_source, skillName) => skillName !== 'filtered-project-skill', + isGlobalSkillExposed: () => false, + }); + + expect(catalog.skills).toEqual([]); + }); + + it('captures a Conversation Skill Snapshot with only exposed sources', () => { + const projectPath = createProjectWithSkill('snapshot-skill'); + + const snapshot = captureConversationSkillSnapshot({ + projectPath, + isGlobalSkillExposed: () => false, + }); + + expect(snapshot.map((skill) => skill.name)).toEqual(['snapshot-skill']); + expect(snapshot[0]).toMatchObject({ + sourceKind: 'project', + skillPath: path.join(projectPath, '.cdf', 'skills', 'snapshot-skill', 'SKILL.md'), + userInvocable: true, + }); + expect(JSON.stringify(snapshot)).not.toContain('Instructions'); + }); +}); diff --git a/src/main/deepagent/skill-catalog.ts b/src/main/deepagent/skill-catalog.ts new file mode 100644 index 00000000..e461fd4f --- /dev/null +++ b/src/main/deepagent/skill-catalog.ts @@ -0,0 +1,156 @@ +import { + deletePhysicalSkill, + getBuiltInSkillDirs, + getBuiltInSkillRegistrations, + getScopePath, + importPhysicalSkillDirectory, + listGlobalSkillViews, + listPhysicalSkills, + listResolvedSkillViews, + resolveAgentSkillsConfig, + resolveConversationSkillSnapshotConfig, + savePhysicalSkill, +} from './skill-manager'; +import { + getSkillDisplayName, + getSkillSourceLabel, + isGlobalSkillSourceKind, + resolveSkillCatalog, + resolveSkillSourcePlan, + type ResolvedSkillCatalog, + type ResolvedSkillCatalogEntry, + type SkillCatalogOptions, + type SkillSourceEntry, +} from './skills-runtime/skill-sources'; +import { + buildCdfSkillsRuntime, + type CdfSkillsRuntime, + type CdfSkillsRuntimeOptions, +} from './skills-runtime/cdf-skills-runtime'; +import type { + ConversationSkillSnapshotEntry, + GlobalSkillSourceKind, +} from '../../shared/skills'; + +/** + * Skill Catalog——"存在哪些 Skill、对谁可见"的唯一回答者。 + * + * 深模块入口:discovery(source plan)、resolution(目录解析 + 遮蔽 + + * Scene 曝光过滤)、Conversation Skill Snapshot 捕获、Settings 视图、 + * 物理 Skill CRUD 与运行时 prompt 视图统一从这里出。skill-manager 与 + * skills-runtime/* 是实现文件,skill 家族之外的消费方不应再直接导入它们。 + * + * Scene Skill Exposure 策略保持在曝光策略模块(ADR-0069),以 + * `isGlobalSkillExposed` 谓词注入;Project Skill 永远不经过该谓词。 + */ + +export { + deletePhysicalSkill, + getBuiltInSkillRegistrations, + importPhysicalSkillDirectory, + listGlobalSkillViews, + listPhysicalSkills, + listResolvedSkillViews, + resolveAgentSkillsConfig, + resolveConversationSkillSnapshotConfig, + savePhysicalSkill, +}; +export { getSkillDisplayName, getSkillSourceLabel, isGlobalSkillSourceKind }; +export type { + CdfSkillsRuntime, + CdfSkillsRuntimeOptions, + ResolvedSkillCatalog, + ResolvedSkillCatalogEntry, + SkillCatalogOptions, +}; + +export type GlobalSkillExposurePredicate = ( + skill: { sourceKind: GlobalSkillSourceKind; name: string } +) => boolean; + +export interface ResolveProjectSkillCatalogOptions extends SkillCatalogOptions { + /** Scene 曝光策略谓词,只作用于 Global(built-in / user)来源。 */ + isGlobalSkillExposed?: GlobalSkillExposurePredicate; +} + +function composeIncludeSkill( + isGlobalSkillExposed: GlobalSkillExposurePredicate | undefined, + includeSkill: SkillCatalogOptions['includeSkill'], +): SkillCatalogOptions['includeSkill'] { + if (!isGlobalSkillExposed) return includeSkill; + return (source: SkillSourceEntry, skillName: string) => { + if (includeSkill && !includeSkill(source, skillName)) return false; + return !isGlobalSkillSourceKind(source.kind) + || isGlobalSkillExposed({ sourceKind: source.kind, name: skillName }); + }; +} + +/** + * 项目 Skill 目录的规范解析:Built-in 目录与用户 Global 目录默认铺入 + * source plan,消费方不再自行拼装 plan → catalog 两步。 + */ +export function resolveProjectSkillCatalog( + projectPath: string, + options: ResolveProjectSkillCatalogOptions = {} +): ResolvedSkillCatalog { + const { isGlobalSkillExposed, includeSkill, ...catalogOptions } = options; + const plan = resolveSkillSourcePlan(projectPath, { + builtInSkillDirs: getBuiltInSkillDirs(), + userSkillsDir: getScopePath(projectPath, 'global'), + includeNestedProjectSkills: options.includeNestedProjectSkills, + }); + return resolveSkillCatalog(plan, { + ...catalogOptions, + includeSkill: composeIncludeSkill(isGlobalSkillExposed, includeSkill), + }); +} + +function toConversationSkillSnapshotEntry( + skill: ResolvedSkillCatalogEntry +): ConversationSkillSnapshotEntry { + return { + name: skill.name, + qualifiedName: skill.qualifiedName, + qualifier: skill.qualifier, + description: skill.description, + argumentHint: skill.argumentHint, + allowedTools: skill.allowedTools, + whenToUse: skill.whenToUse, + arguments: skill.arguments, + sourceKind: skill.sourceKind, + sourcePath: skill.sourcePath, + skillPath: skill.skillPath, + modelDiscovery: skill.modelDiscovery, + userInvocable: skill.userInvocable, + }; +} + +/** + * 捕获 Conversation Skill Snapshot:Conversation 创建时冻结的 Skill 身份与 + * 发现元数据集合。持久化(sessions 表读写)由快照持久化模块负责。 + */ +export function captureConversationSkillSnapshot(input: { + projectPath: string; + isGlobalSkillExposed: GlobalSkillExposurePredicate; +}): ConversationSkillSnapshotEntry[] { + const catalog = resolveProjectSkillCatalog(input.projectPath, { + includeNestedProjectSkills: true, + isGlobalSkillExposed: input.isGlobalSkillExposed, + }); + return catalog.skills.map(toConversationSkillSnapshotEntry); +} + +/** + * 运行时 Skill 视图(已解析集合 + prompt + attributions),Built-in 与用户 + * Global 目录默认铺入;传入 `catalog`(冻结快照)时不做磁盘发现。 + */ +export function buildProjectSkillsRuntime( + projectPath: string, + options: CdfSkillsRuntimeOptions = {} +): CdfSkillsRuntime { + return buildCdfSkillsRuntime(projectPath, { + builtInSkillDirs: getBuiltInSkillDirs(), + userSkillsDir: getScopePath(projectPath, 'global'), + ...options, + }); +} diff --git a/src/main/deepagent/skill-manager.ts b/src/main/deepagent/skill-manager.ts index f978fcef..621d498a 100644 --- a/src/main/deepagent/skill-manager.ts +++ b/src/main/deepagent/skill-manager.ts @@ -13,6 +13,7 @@ import { getAcademicStyleRevisionSkillMarkdown, getAcademicStyleRevisionSkillRes import { getPaperSearchSkillMarkdown, getPaperSearchSkillResources } from '../paper-search-skill'; import { getPdfParsingSkillMarkdown, getPdfParsingSkillResources } from '../pdf-parsing-skill'; import { + getSkillSourceLabel, invalidateSkillSourceCaches, resolveSkillCatalog, resolveSkillSourcePlan, @@ -433,23 +434,6 @@ function getResolvedSkillScope(skill: ResolvedSkillCatalogEntry): SkillScope { return classifySkillSourceKind(skill.sourceKind) === 'global' ? 'global' : 'project'; } -function getResolvedSkillSourceLabel(skill: Pick): string { - switch (skill.sourceKind) { - case 'built-in': - return 'Built-in Skill'; - case 'project': - return 'Project Skill'; - case 'project-nested': - return skill.qualifier ? `Nested Project Skill: ${skill.qualifier}` : 'Nested Project Skill'; - case 'project-additional': - return skill.qualifier ? `Project Skill: ${skill.qualifier}` : 'Project Skill'; - case 'user': - return 'Global Skill'; - case 'enterprise': - return 'Managed Skill'; - } -} - function isResolvedSkillEditable(skill: ResolvedSkillCatalogEntry): boolean { return skill.sourceKind === 'project' || skill.sourceKind === 'user'; } @@ -465,7 +449,7 @@ function buildResolvedSkillView(skill: ResolvedSkillCatalogEntry): PhysicalSkill description: skill.description, scope: getResolvedSkillScope(skill), sourceKind: skill.sourceKind, - sourceLabel: getResolvedSkillSourceLabel(skill), + sourceLabel: getSkillSourceLabel(skill), sourcePath: skill.sourcePath, skillPath: skill.skillPath, modelDiscovery: skill.modelDiscovery, @@ -478,7 +462,7 @@ function buildResolvedSkillView(skill: ResolvedSkillCatalogEntry): PhysicalSkill name: shadowed.name, qualifiedName: shadowed.qualifiedName ?? shadowed.name, sourceKind: shadowed.sourceKind, - sourceLabel: getResolvedSkillSourceLabel(shadowed), + sourceLabel: getSkillSourceLabel(shadowed), sourcePath: shadowed.sourcePath, skillPath: shadowed.skillPath, })), diff --git a/src/main/deepagent/skills-runtime/cdf-skills-runtime.ts b/src/main/deepagent/skills-runtime/cdf-skills-runtime.ts index c20ceee4..cadb8335 100644 --- a/src/main/deepagent/skills-runtime/cdf-skills-runtime.ts +++ b/src/main/deepagent/skills-runtime/cdf-skills-runtime.ts @@ -1,6 +1,9 @@ import fs from 'fs'; import { renderCdfSkillsPrompt } from './skill-prompt'; import { + getSkillDisplayName, + getSkillSourceLabel, + isGlobalSkillSourceKind, resolveSkillCatalog, resolveSkillSourcePlan, type ResolvedSkillCatalogEntry, @@ -8,7 +11,7 @@ import { type SkillSourcePlanOptions, } from './skill-sources'; import type { SkillAttribution } from '../../../shared/types'; -import type { GlobalSkillSourceKind, SkillSourceKind } from '../../../shared/skills'; +import type { GlobalSkillSourceKind } from '../../../shared/skills'; import { resolvedGlobalSkillKey } from '../../../shared/skill-identifiers'; export interface CdfSkillsRuntimeOptions extends SkillSourcePlanOptions, SkillCatalogOptions { @@ -23,10 +26,6 @@ export interface CdfSkillsRuntimeOptions extends SkillSourcePlanOptions, SkillCa isGlobalSkillExposed?: (skill: { sourceKind: GlobalSkillSourceKind; name: string }) => boolean; } -function isGlobalSkillSourceKind(sourceKind: SkillSourceKind): sourceKind is GlobalSkillSourceKind { - return sourceKind === 'built-in' || sourceKind === 'user'; -} - export interface CdfSkillsRuntime { skills: ResolvedSkillCatalogEntry[]; prompt: string; @@ -42,27 +41,6 @@ function stripSkillFrontmatter(content: string): string { : content.slice(end + '\n---'.length).replace(/^\s+/, ''); } -function getSkillDisplayName(skill: ResolvedSkillCatalogEntry): string { - return skill.qualifiedName ?? skill.name; -} - -function getSkillSourceLabel(skill: ResolvedSkillCatalogEntry): string { - switch (skill.sourceKind) { - case 'built-in': - return 'Built-in Skill'; - case 'project': - return 'Project Skill'; - case 'project-nested': - return skill.qualifier ? `Nested Project Skill: ${skill.qualifier}` : 'Nested Project Skill'; - case 'project-additional': - return skill.qualifier ? `Project Skill: ${skill.qualifier}` : 'Project Skill'; - case 'user': - return 'Global Skill'; - case 'enterprise': - return 'Managed Skill'; - } -} - function isPreloadedSkill( skill: ResolvedSkillCatalogEntry, preloadSkillNames: string[] | undefined, diff --git a/src/main/deepagent/skills-runtime/skill-sources.test.ts b/src/main/deepagent/skills-runtime/skill-sources.test.ts index 92d7f734..f074a9c4 100644 --- a/src/main/deepagent/skills-runtime/skill-sources.test.ts +++ b/src/main/deepagent/skills-runtime/skill-sources.test.ts @@ -3,6 +3,7 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { + getSkillSourceLabel, invalidateSkillSourceCaches, resolveSkillCatalog, resolveSkillSourcePlan, @@ -31,6 +32,25 @@ function writeSkill( ); } +describe('getSkillSourceLabel', () => { + it.each([ + [{ sourceKind: 'built-in' as const }, 'Built-in Skill'], + [{ sourceKind: 'project' as const }, 'Project Skill'], + [ + { sourceKind: 'project-nested' as const, qualifier: 'apps/web' }, + 'Nested Project Skill: apps/web', + ], + [ + { sourceKind: 'project-additional' as const, qualifier: 'docs' }, + 'Project Skill: docs', + ], + [{ sourceKind: 'user' as const }, 'Global Skill'], + [{ sourceKind: 'enterprise' as const }, 'Managed Skill'], + ])('maps $sourceKind to its canonical label', (skill, expectedLabel) => { + expect(getSkillSourceLabel(skill)).toBe(expectedLabel); + }); +}); + describe('resolveSkillSourcePlan', () => { const tempProjectPath = path.join(os.tmpdir(), `cdf-skill-source-test-${Math.random().toString(36).slice(2)}`); const tempHomePath = path.join(os.tmpdir(), `cdf-skill-source-home-${Math.random().toString(36).slice(2)}`); diff --git a/src/main/deepagent/skills-runtime/skill-sources.ts b/src/main/deepagent/skills-runtime/skill-sources.ts index ba92f7d4..1b9f5f1c 100644 --- a/src/main/deepagent/skills-runtime/skill-sources.ts +++ b/src/main/deepagent/skills-runtime/skill-sources.ts @@ -1,7 +1,7 @@ import fs from 'fs'; import path from 'path'; import { parseSkillMetadata } from './skill-metadata'; -import type { SkillSourceKind } from '../../../shared/skills'; +import type { GlobalSkillSourceKind, SkillSourceKind } from '../../../shared/skills'; export type { SkillSourceKind } from '../../../shared/skills'; import type { SkillModelDiscovery } from '../../../shared/skills'; @@ -60,6 +60,37 @@ export interface ResolvedSkillCatalog { warnings: string[]; } +export function isGlobalSkillSourceKind( + sourceKind: SkillSourceKind +): sourceKind is GlobalSkillSourceKind { + return sourceKind === 'built-in' || sourceKind === 'user'; +} + +export function getSkillDisplayName( + skill: Pick +): string { + return skill.qualifiedName ?? skill.name; +} + +export function getSkillSourceLabel( + skill: Pick +): string { + switch (skill.sourceKind) { + case 'built-in': + return 'Built-in Skill'; + case 'project': + return 'Project Skill'; + case 'project-nested': + return skill.qualifier ? `Nested Project Skill: ${skill.qualifier}` : 'Nested Project Skill'; + case 'project-additional': + return skill.qualifier ? `Project Skill: ${skill.qualifier}` : 'Project Skill'; + case 'user': + return 'Global Skill'; + case 'enterprise': + return 'Managed Skill'; + } +} + export interface SkillSourcePlanOptions { builtInSkillDirs?: string[]; userSkillsDir?: string | null; diff --git a/src/main/flow-diagram/flow-diagram-document-store.test.ts b/src/main/flow-diagram/flow-diagram-document-store.test.ts new file mode 100644 index 00000000..b64e925e --- /dev/null +++ b/src/main/flow-diagram/flow-diagram-document-store.test.ts @@ -0,0 +1,339 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import type { + FlowDiagramDocumentChangeEvent, + FlowDiagramDocumentVersion, +} from '../../shared/flow-diagrams'; +import { createFlowDiagramDocumentStore } from './flow-diagram-document-store'; + +function sceneJson(elementIds: string[] = []): string { + return JSON.stringify({ + type: 'excalidraw', + version: 2, + source: 'https://cdf.local', + elements: elementIds.map((id) => ({ + id, + type: 'rectangle', + x: 10, + y: 10, + width: 100, + height: 60, + })), + appState: { viewBackgroundColor: '#ffffff' }, + files: {}, + }, null, 2); +} + +describe('FlowDiagramDocumentStore', () => { + let projectPath: string; + let stateRoot: string; + let filePath: string; + + beforeEach(() => { + projectPath = fs.mkdtempSync(path.join(os.tmpdir(), 'cdf-flow-doc-store-')); + stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cdf-flow-doc-state-')); + filePath = path.join(projectPath, 'diagram.excalidraw'); + }); + + afterEach(() => { + fs.rmSync(projectPath, { recursive: true, force: true }); + fs.rmSync(stateRoot, { recursive: true, force: true }); + }); + + it('reads the authoritative document with an opaque content version', async () => { + const original = sceneJson(['one']); + fs.writeFileSync(filePath, original); + const store = createFlowDiagramDocumentStore({ projectPath }); + + const result = await store.readDocument('diagram.excalidraw'); + + expect(result).toEqual({ + ok: true, + document: { + content: original, + version: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }); + }); + + it('creates a new document without overwriting an existing path', async () => { + const store = createFlowDiagramDocumentStore({ projectPath, stateRoot }); + const content = sceneJson(['one']); + + const created = await store.createDocument('diagram.excalidraw', content); + const duplicate = await store.createDocument('diagram.excalidraw', sceneJson(['two'])); + + expect(created).toEqual({ + ok: true, + filePath, + document: { + content, + version: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }); + expect(duplicate).toMatchObject({ ok: false, error: { code: 'FILE_EXISTS' } }); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(content); + }); + + it('allocates collision-safe paths for generated document names inside the Store', async () => { + const store = createFlowDiagramDocumentStore({ projectPath, stateRoot }); + + const first = await store.createDocument( + 'diagrams/generated.excalidraw', + sceneJson(['one']), + { collisionSafe: true }, + ); + const second = await store.createDocument( + 'diagrams/generated.excalidraw', + sceneJson(['two']), + { collisionSafe: true }, + ); + + expect(first).toMatchObject({ + ok: true, + filePath: path.join(projectPath, 'diagrams', 'generated.excalidraw'), + }); + expect(second).toMatchObject({ + ok: true, + filePath: path.join(projectPath, 'diagrams', 'generated-2.excalidraw'), + }); + }); + + it('atomically replaces the document when the base content still matches', async () => { + const original = sceneJson(['one']); + fs.writeFileSync(filePath, original); + const notified: string[] = []; + const documentChanges: FlowDiagramDocumentChangeEvent[] = []; + const store = createFlowDiagramDocumentStore({ + projectPath, + notifyFileChange: (changed) => notified.push(changed), + notifyDocumentChange: (change) => documentChanges.push(change), + }); + const initial = await store.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + + const next = sceneJson(['one', 'two']); + const result = await store.saveDocument( + 'diagram.excalidraw', + next, + initial.document.version, + 'renderer-save-1', + ); + + expect(result).toEqual({ + ok: true, + document: { + content: next, + version: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }); + if (result.ok) { + expect(result.document.version).not.toBe(initial.document.version); + } + expect(fs.readFileSync(filePath, 'utf-8')).toBe(next); + expect(notified).toEqual([filePath]); + expect(documentChanges).toEqual([{ + filePath, + version: result.ok ? result.document.version : null, + mutationId: 'renderer-save-1', + }]); + expect(fs.readdirSync(projectPath).filter((name) => name.includes('cdf-tmp'))).toEqual([]); + }); + + it('does not report a published document as failed when a notifier throws', async () => { + const original = sceneJson(['one']); + const next = sceneJson(['two']); + fs.writeFileSync(filePath, original); + const store = createFlowDiagramDocumentStore({ + projectPath, + notifyFileChange: () => { + throw new Error('watcher unavailable'); + }, + notifyDocumentChange: () => { + throw new Error('window closed'); + }, + }); + const initial = await store.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + + const result = await store.saveDocument( + 'diagram.excalidraw', + next, + initial.document.version, + ); + + expect(result).toMatchObject({ ok: true, document: { content: next } }); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(next); + }); + + it('returns a conflict with the current content when the document changed externally', async () => { + const original = sceneJson(['one']); + const external = sceneJson(['agent']); + fs.writeFileSync(filePath, original); + const store = createFlowDiagramDocumentStore({ projectPath }); + const initial = await store.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + fs.writeFileSync(filePath, external); + + const result = await store.saveDocument( + 'diagram.excalidraw', + sceneJson(['mine']), + initial.document.version, + ); + + expect(result).toEqual({ + ok: false, + error: { + code: 'SOURCE_CHANGED', + message: expect.stringContaining('changed'), + currentContent: external, + currentVersion: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(external); + }); + + it('serializes concurrent saves so the later writer observes the earlier one', async () => { + const original = sceneJson(['one']); + fs.writeFileSync(filePath, original); + const store = createFlowDiagramDocumentStore({ projectPath }); + const initial = await store.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + + const [first, second] = await Promise.all([ + store.saveDocument('diagram.excalidraw', sceneJson(['first']), initial.document.version), + store.saveDocument('diagram.excalidraw', sceneJson(['second']), initial.document.version), + ]); + + const outcomes = [first, second]; + expect(outcomes.filter((outcome) => outcome.ok)).toHaveLength(1); + const conflict = outcomes.find((outcome) => !outcome.ok); + expect(conflict).toMatchObject({ ok: false, error: { code: 'SOURCE_CHANGED' } }); + const finalContent = fs.readFileSync(filePath, 'utf-8'); + expect([sceneJson(['first']), sceneJson(['second'])]).toContain(finalContent); + }); + + it('revalidates after the controlled pre-publication race seam', async () => { + const original = sceneJson(['one']); + const external = sceneJson(['external']); + fs.writeFileSync(filePath, original); + const initialStore = createFlowDiagramDocumentStore({ projectPath }); + const initial = await initialStore.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + const store = createFlowDiagramDocumentStore({ + projectPath, + beforePublish: () => { + fs.writeFileSync(filePath, external); + }, + }); + + const result = await store.saveDocument( + 'diagram.excalidraw', + sceneJson(['mine']), + initial.document.version, + ); + + expect(result).toEqual({ + ok: false, + error: { + code: 'SOURCE_CHANGED', + message: expect.stringContaining('changed'), + currentContent: external, + currentVersion: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + }); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(external); + expect(fs.readdirSync(projectPath).filter((name) => name.includes('cdf-tmp'))).toEqual([]); + }); + + it('owns Agent revision recording and rollback as one document boundary', async () => { + const original = sceneJson(['one']); + const edited = sceneJson(['one', 'agent']); + fs.writeFileSync(filePath, original); + const store = createFlowDiagramDocumentStore({ projectPath, stateRoot }); + const initial = await store.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + + const editResult = await store.applyAgentEdit( + 'diagram.excalidraw', + edited, + initial.document.version, + ); + const rollbackResult = await store.rollbackDocument('diagram.excalidraw'); + + expect(editResult).toMatchObject({ + ok: true, + filePath, + document: { content: edited }, + }); + expect(rollbackResult).toMatchObject({ + ok: true, + filePath, + document: { content: original }, + }); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original); + }); + + it('creates derived exports without exposing file writes to the service', async () => { + fs.writeFileSync(filePath, sceneJson(['one'])); + const store = createFlowDiagramDocumentStore({ projectPath, stateRoot }); + const bytes = Buffer.from(''); + + const created = await store.createExport({ + sourceFilePath: 'diagram.excalidraw', + format: 'svg', + bytes, + }); + const next = await store.createExport({ + sourceFilePath: 'diagram.excalidraw', + format: 'svg', + bytes, + }); + + expect(created).toEqual({ + ok: true, + filePath: path.join(projectPath, 'diagram.svg'), + }); + expect(next).toEqual({ + ok: true, + filePath: path.join(projectPath, 'diagram-2.svg'), + }); + expect(fs.readFileSync(path.join(projectPath, 'diagram.svg'))).toEqual(bytes); + }); + + it('rejects invalid scenes at the write boundary without touching the document', async () => { + const original = sceneJson(['one']); + fs.writeFileSync(filePath, original); + const store = createFlowDiagramDocumentStore({ projectPath }); + const initial = await store.readDocument('diagram.excalidraw'); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); + + const result = await store.saveDocument( + 'diagram.excalidraw', + '{"not":"excalidraw"}', + initial.document.version, + ); + + expect(result.ok).toBe(false); + expect(fs.readFileSync(filePath, 'utf-8')).toBe(original); + }); + + it('rejects documents outside the Project and non-diagram extensions', async () => { + const store = createFlowDiagramDocumentStore({ projectPath }); + const version = 'untrusted-version' as FlowDiagramDocumentVersion; + + await expect(store.saveDocument('../outside.excalidraw', sceneJson(), version)) + .resolves.toMatchObject({ ok: false, error: { code: 'PATH_OUTSIDE_PROJECT' } }); + await expect(store.saveDocument('notes.txt', sceneJson(), version)) + .resolves.toMatchObject({ ok: false, error: { code: 'INVALID_EXTENSION' } }); + }); +}); diff --git a/src/main/flow-diagram/flow-diagram-document-store.ts b/src/main/flow-diagram/flow-diagram-document-store.ts new file mode 100644 index 00000000..e025bf33 --- /dev/null +++ b/src/main/flow-diagram/flow-diagram-document-store.ts @@ -0,0 +1,662 @@ +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import log from '../logger'; +import { runProjectFileMutation } from '../services/project-file-mutation'; +import { + FlowDiagramSceneError, + parseFlowDiagramScene, +} from './flow-diagram-scene'; +import { + createFlowDiagramRevisionStore, + type FlowDiagramRevisionStore, +} from './flow-diagram-revision-store'; +import { + FLOW_DIAGRAM_SOURCE_CHANGED, + type FlowDiagramDocumentChangeEvent, + type FlowDiagramDocumentReadResult, + type FlowDiagramDocumentSaveResult, + type FlowDiagramDocumentSnapshot, + type FlowDiagramDocumentVersion, + type FlowDiagramExportFormat, +} from '../../shared/flow-diagrams'; + +/** + * Flow Diagram 文档存储:`.excalidraw` 文档一致性的唯一拥有者。 + * + * CDF 内部写入按 Project 串行化并使用 opaque content version;发布前会 + * 再次校验版本,随后以 rename 原子发布完整文档。任意外部程序不参与同一 + * 协议,因此外部冲突检测是 best-effort,而非跨平台严格 CAS(ADR-0073)。 + */ + +export class FlowDiagramOperationError extends Error { + constructor( + public readonly code: string, + message: string, + ) { + super(message); + this.name = 'FlowDiagramOperationError'; + } +} + +const PROTECTED_SEGMENTS = new Set(['.git', '.cdf', 'node_modules', 'out', 'dist']); + +function isWithin(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function nearestExistingAncestor(candidatePath: string): string { + let current = candidatePath; + while (!fs.existsSync(current)) { + const parent = path.dirname(current); + if (parent === current) break; + current = parent; + } + return current; +} + +function resolveProjectOwnedPath( + projectPath: string, + requestedPath: string, + expectedExtension: string, +): string { + const trimmed = requestedPath.trim(); + if (!trimmed) { + throw new FlowDiagramOperationError('PATH_REQUIRED', 'A non-empty Project path is required.'); + } + if (trimmed.startsWith('~')) { + throw new FlowDiagramOperationError('PATH_OUTSIDE_PROJECT', 'Home-relative paths are not allowed.'); + } + const resolvedProjectPath = fs.realpathSync(projectPath); + const target = path.resolve(projectPath, trimmed); + if (!isWithin(path.resolve(projectPath), target)) { + throw new FlowDiagramOperationError( + 'PATH_OUTSIDE_PROJECT', + 'The requested path is outside the current Project.', + ); + } + const relative = path.relative(projectPath, target); + const segments = relative.toLowerCase().split(path.sep).filter(Boolean); + if ( + segments.some((segment) => PROTECTED_SEGMENTS.has(segment)) + || segments.some((segment) => segment === '.env' || segment.startsWith('.env.')) + ) { + throw new FlowDiagramOperationError( + 'PROTECTED_PATH', + 'The requested path is protected and cannot be used for a Flow Diagram.', + ); + } + if (path.extname(target).toLowerCase() !== expectedExtension) { + throw new FlowDiagramOperationError( + 'INVALID_EXTENSION', + `The requested path must end with ${expectedExtension}.`, + ); + } + + const ancestor = nearestExistingAncestor(target); + const realAncestor = fs.realpathSync(ancestor); + if (!isWithin(resolvedProjectPath, realAncestor)) { + throw new FlowDiagramOperationError( + 'PATH_OUTSIDE_PROJECT', + 'The requested path resolves outside the current Project.', + ); + } + if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) { + throw new FlowDiagramOperationError('SYMLINK_NOT_ALLOWED', 'Flow Diagram paths cannot be symlinks.'); + } + return target; +} + +export function hashBytes(bytes: Buffer): FlowDiagramDocumentVersion { + return crypto.createHash('sha256').update(bytes).digest('hex') as FlowDiagramDocumentVersion; +} + +function temporaryPathFor(targetPath: string): string { + return path.join( + path.dirname(targetPath), + `.${path.basename(targetPath)}.cdf-tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`, + ); +} + +function collisionSafePath(basePath: string): string { + if (!fs.existsSync(basePath)) return basePath; + const extension = path.extname(basePath); + const stem = basePath.slice(0, -extension.length); + for (let suffix = 2; suffix < 10_000; suffix += 1) { + const candidate = `${stem}-${suffix}${extension}`; + if (!fs.existsSync(candidate)) return candidate; + } + throw new FlowDiagramOperationError( + 'PATH_COLLISION', + 'Could not allocate a collision-safe Flow Diagram path.', + ); +} + +async function withPreparedTemporaryFile( + filePath: string, + bytes: Buffer, + publish: (temporaryPath: string) => Promise, +): Promise { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temporaryPath = temporaryPathFor(filePath); + try { + const handle = await fs.promises.open(temporaryPath, 'wx', 0o600); + try { + await handle.writeFile(bytes); + await handle.sync(); + } finally { + await handle.close(); + } + return await publish(temporaryPath); + } finally { + await fs.promises.rm(temporaryPath, { force: true }); + } +} + +async function writeNewFileAtomically(filePath: string, bytes: Buffer): Promise { + await withPreparedTemporaryFile(filePath, bytes, async (temporaryPath) => { + try { + await fs.promises.link(temporaryPath, filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'EEXIST') { + throw new FlowDiagramOperationError( + 'FILE_EXISTS', + 'The requested output already exists; no file was overwritten.', + ); + } + throw error; + } + }); +} + +export interface CreateFlowDiagramDocumentStoreOptions { + projectPath: string; + stateRoot?: string; + revisionStore?: FlowDiagramRevisionStore; + notifyFileChange?: (filePath: string) => void; + notifyDocumentChange?: (event: FlowDiagramDocumentChangeEvent) => void; + /** Controlled race seam used to verify the documented external-writer guarantee. */ + beforePublish?: (context: { + filePath: string; + expectedVersion: FlowDiagramDocumentVersion; + }) => void | Promise; +} + +export type FlowDiagramDocumentMutationResult = + | { + ok: true; + filePath: string; + document: FlowDiagramDocumentSnapshot; + } + | { + ok: false; + error: { + code: string; + message: string; + currentContent?: string | null; + currentVersion?: FlowDiagramDocumentVersion | null; + }; + }; + +export type FlowDiagramExportWriteResult = + | { ok: true; filePath: string } + | { ok: false; error: { code: string; message: string } }; + +export interface FlowDiagramDocumentStore { + /** Reads and validates the authoritative document together with its opaque byte identity. */ + readDocument(filePath: string): Promise; + + /** Creates exactly one new Project-owned document and never overwrites an existing path. */ + createDocument( + filePath: string, + content: string, + options?: { collisionSafe?: boolean }, + ): Promise; + + /** Records the current revision and applies one Agent-produced candidate document. */ + applyAgentEdit( + filePath: string, + content: string, + expectedVersion: FlowDiagramDocumentVersion, + ): Promise; + + /** Restores and consumes the latest applicable Agent revision. */ + rollbackDocument(filePath: string): Promise; + + /** Publishes one derived export without overwriting an explicit output path. */ + createExport(input: { + sourceFilePath: string; + requestedOutputPath?: string; + format: FlowDiagramExportFormat; + bytes: Buffer; + }): Promise; + + /** + * Editor autosave entry: validates the scene, then publishes only while the + * authoritative document still has `expectedVersion`. + */ + saveDocument( + filePath: string, + content: string, + expectedVersion: FlowDiagramDocumentVersion, + mutationId?: string, + ): Promise; +} + +function readCurrentBytes(filePath: string): Buffer { + try { + return fs.readFileSync(filePath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new FlowDiagramOperationError( + FLOW_DIAGRAM_SOURCE_CHANGED, + 'The Flow Diagram changed before the operation could be applied.', + ); + } + throw error; + } +} + +function assertCurrentVersion( + filePath: string, + expectedVersion: FlowDiagramDocumentVersion, +): void { + if (hashBytes(readCurrentBytes(filePath)) !== expectedVersion) { + throw new FlowDiagramOperationError( + FLOW_DIAGRAM_SOURCE_CHANGED, + 'The Flow Diagram changed before the operation could be applied.', + ); + } +} + +async function replaceFileAtomicallyIfVersionMatches( + filePath: string, + bytes: Buffer, + expectedVersion: FlowDiagramDocumentVersion, + beforePublish?: CreateFlowDiagramDocumentStoreOptions['beforePublish'], +): Promise { + await withPreparedTemporaryFile(filePath, bytes, async (temporaryPath) => { + assertCurrentVersion(filePath, expectedVersion); + await beforePublish?.({ filePath, expectedVersion }); + // Revalidate after the controlled race seam, immediately before the one + // atomic publication step. See ADR-0073 for the remaining external gap. + assertCurrentVersion(filePath, expectedVersion); + await fs.promises.rename(temporaryPath, filePath); + }); +} + +export function createFlowDiagramDocumentStore( + options: CreateFlowDiagramDocumentStoreOptions, +): FlowDiagramDocumentStore { + const projectPath = path.resolve(options.projectPath); + const notifyFile = (filePath: string) => { + try { + options.notifyFileChange?.(filePath); + } catch (error) { + log.warn('[flow-diagram-store] file notification failed after publication:', error); + } + }; + const notifyDocument = ( + filePath: string, + version: FlowDiagramDocumentVersion, + mutationId?: string, + ) => { + notifyFile(filePath); + try { + options.notifyDocumentChange?.({ filePath, version, mutationId }); + } catch (error) { + log.warn('[flow-diagram-store] document notification failed after publication:', error); + } + }; + let revisionStore = options.revisionStore; + const revisions = (): FlowDiagramRevisionStore => { + if (!revisionStore) { + if (!options.stateRoot) { + throw new FlowDiagramOperationError( + 'REVISION_STORE_UNAVAILABLE', + 'Flow Diagram revision storage is not configured.', + ); + } + revisionStore = createFlowDiagramRevisionStore(projectPath, options.stateRoot); + } + return revisionStore; + }; + + return { + async readDocument(filePath) { + try { + const target = resolveProjectOwnedPath(projectPath, filePath, '.excalidraw'); + const bytes = fs.readFileSync(target); + const content = bytes.toString('utf-8'); + parseFlowDiagramScene(content); + return { + ok: true, + document: { content, version: hashBytes(bytes) }, + }; + } catch (error) { + return failureResult(error, 'READ_FAILED'); + } + }, + + async createDocument(filePath, content, createOptions) { + let requestedTarget: string; + try { + requestedTarget = resolveProjectOwnedPath(projectPath, filePath, '.excalidraw'); + parseFlowDiagramScene(content); + } catch (error) { + return failureResult(error, 'CREATE_FAILED'); + } + + return runProjectFileMutation(projectPath, async () => { + const bytes = Buffer.from(content, 'utf-8'); + try { + const target = createOptions?.collisionSafe + ? collisionSafePath(requestedTarget) + : requestedTarget; + await writeNewFileAtomically(target, bytes); + const version = hashBytes(bytes); + notifyDocument(target, version); + return { + ok: true, + filePath: target, + document: { content, version }, + }; + } catch (error) { + return failureResult(error, 'CREATE_FAILED'); + } + }); + }, + + async applyAgentEdit(filePath, content, expectedVersion) { + let target: string; + let candidateBytes: Buffer; + try { + target = resolveProjectOwnedPath(projectPath, filePath, '.excalidraw'); + parseFlowDiagramScene(content); + candidateBytes = Buffer.from(content, 'utf-8'); + } catch (error) { + return failureResult(error, 'INVALID_SCENE'); + } + + return runProjectFileMutation(projectPath, async () => { + let currentBytes: Buffer; + try { + currentBytes = readCurrentBytes(target); + parseFlowDiagramScene(currentBytes); + assertCurrentVersion(target, expectedVersion); + } catch (error) { + return error instanceof FlowDiagramOperationError + && error.code === FLOW_DIAGRAM_SOURCE_CHANGED + ? sourceChangedResult(target, error.message) + : failureResult(error, 'READ_FAILED'); + } + + let revisionToken: string; + try { + revisionToken = await revisions().record(target, currentBytes, candidateBytes); + } catch (error) { + return failureResult( + new FlowDiagramOperationError('REVISION_FAILED', safeErrorMessage(error)), + 'REVISION_FAILED', + ); + } + + try { + await replaceFileAtomicallyIfVersionMatches( + target, + candidateBytes, + expectedVersion, + options.beforePublish, + ); + } catch (error) { + try { + await revisions().consumeLatest(target, revisionToken); + } catch (cleanupError) { + return failureResult( + new FlowDiagramOperationError('REVISION_FAILED', safeErrorMessage(cleanupError)), + 'REVISION_FAILED', + ); + } + return error instanceof FlowDiagramOperationError + && error.code === FLOW_DIAGRAM_SOURCE_CHANGED + ? sourceChangedResult(target, error.message) + : failureResult(error, 'WRITE_FAILED'); + } + + const version = hashBytes(candidateBytes); + notifyDocument(target, version); + return { + ok: true, + filePath: target, + document: { content, version }, + }; + }); + }, + + async rollbackDocument(filePath) { + let target: string; + try { + target = resolveProjectOwnedPath(projectPath, filePath, '.excalidraw'); + } catch (error) { + return failureResult(error, 'ROLLBACK_FAILED'); + } + + return runProjectFileMutation(projectPath, async () => { + let currentBytes: Buffer; + try { + currentBytes = readCurrentBytes(target); + parseFlowDiagramScene(currentBytes); + } catch (error) { + return failureResult(error, 'ROLLBACK_FAILED'); + } + + let revision; + try { + revision = await revisions().peekLatest(target); + if (!revision) { + throw new FlowDiagramOperationError( + 'NO_REVISION', + 'No applicable Agent edit revision is available for this Flow Diagram.', + ); + } + parseFlowDiagramScene(revision.sourceBytes); + } catch (error) { + return failureResult(error, 'ROLLBACK_FAILED'); + } + + const currentVersion = hashBytes(currentBytes); + if (currentVersion !== revision.appliedSourceHash) { + return sourceChangedResult( + target, + 'The Flow Diagram changed after the latest Agent edit; rollback was not applied.', + ); + } + + try { + await replaceFileAtomicallyIfVersionMatches( + target, + revision.sourceBytes, + currentVersion, + options.beforePublish, + ); + } catch (error) { + return error instanceof FlowDiagramOperationError + && error.code === FLOW_DIAGRAM_SOURCE_CHANGED + ? sourceChangedResult(target, error.message) + : failureResult(error, 'ROLLBACK_FAILED'); + } + + const rollbackVersion = hashBytes(revision.sourceBytes); + try { + await revisions().consumeLatest(target, revision.token); + } catch (error) { + try { + await replaceFileAtomicallyIfVersionMatches( + target, + currentBytes, + rollbackVersion, + ); + } catch { + return failureResult( + new FlowDiagramOperationError( + 'ROLLBACK_RESTORE_FAILED', + 'Rollback failed and the previous source could not be restored.', + ), + 'ROLLBACK_RESTORE_FAILED', + ); + } + return failureResult( + new FlowDiagramOperationError('REVISION_FAILED', safeErrorMessage(error)), + 'REVISION_FAILED', + ); + } + + const content = revision.sourceBytes.toString('utf-8'); + notifyDocument(target, rollbackVersion); + return { + ok: true, + filePath: target, + document: { content, version: rollbackVersion }, + }; + }); + }, + + async createExport(input) { + let requestedTarget: string; + try { + const source = resolveProjectOwnedPath( + projectPath, + input.sourceFilePath, + '.excalidraw', + ); + const extension = `.${input.format}`; + requestedTarget = input.requestedOutputPath + ? resolveProjectOwnedPath(projectPath, input.requestedOutputPath, extension) + : resolveProjectOwnedPath( + projectPath, + path.join( + path.dirname(path.relative(projectPath, source)), + `${path.basename(source, '.excalidraw')}${extension}`, + ), + extension, + ); + } catch (error) { + return failureResult(error, 'EXPORT_FAILED'); + } + + return runProjectFileMutation(projectPath, async () => { + try { + const target = input.requestedOutputPath + ? requestedTarget + : collisionSafePath(requestedTarget); + await writeNewFileAtomically(target, input.bytes); + notifyFile(target); + return { ok: true, filePath: target }; + } catch (error) { + return failureResult(error, 'EXPORT_FAILED'); + } + }); + }, + + async saveDocument(filePath, content, expectedVersion, mutationId) { + let target: string; + try { + target = resolveProjectOwnedPath(projectPath, filePath, '.excalidraw'); + } catch (error) { + return failureResult(error, 'PATH_OUTSIDE_PROJECT'); + } + try { + parseFlowDiagramScene(content); + } catch (error) { + return failureResult(error, 'INVALID_SCENE'); + } + + return runProjectFileMutation(projectPath, async () => { + try { + const bytes = Buffer.from(content, 'utf-8'); + await replaceFileAtomicallyIfVersionMatches( + target, + bytes, + expectedVersion, + options.beforePublish, + ); + const version = hashBytes(bytes); + notifyDocument(target, version, mutationId); + return { + ok: true, + document: { content, version }, + } as const; + } catch (error) { + if ( + error instanceof FlowDiagramOperationError + && error.code === FLOW_DIAGRAM_SOURCE_CHANGED + ) { + let currentContent: string | null = null; + let currentVersion: FlowDiagramDocumentVersion | null = null; + try { + const currentBytes = fs.readFileSync(target); + currentContent = currentBytes.toString('utf-8'); + currentVersion = hashBytes(currentBytes); + } catch { + currentContent = null; + } + return { + ok: false as const, + error: { + code: FLOW_DIAGRAM_SOURCE_CHANGED, + message: error.message, + currentContent, + currentVersion, + }, + }; + } + return failureResult(error, 'WRITE_FAILED'); + } + }); + }, + }; +} + +function failureResult( + error: unknown, + fallbackCode: string, +): Extract { + if (error instanceof FlowDiagramOperationError || error instanceof FlowDiagramSceneError) { + return { ok: false, error: { code: error.code, message: error.message } }; + } + return { + ok: false, + error: { code: fallbackCode, message: 'The Flow Diagram document could not be saved safely.' }, + }; +} + +function safeErrorMessage(error: unknown): string { + return error instanceof Error + ? error.message + : 'The Flow Diagram operation could not be completed safely.'; +} + +function sourceChangedResult( + filePath: string, + message: string, +): FlowDiagramDocumentMutationResult { + let currentContent: string | null = null; + let currentVersion: FlowDiagramDocumentVersion | null = null; + try { + const currentBytes = fs.readFileSync(filePath); + currentContent = currentBytes.toString('utf-8'); + currentVersion = hashBytes(currentBytes); + } catch { + currentContent = null; + } + return { + ok: false, + error: { + code: FLOW_DIAGRAM_SOURCE_CHANGED, + message, + currentContent, + currentVersion, + }, + }; +} diff --git a/src/main/flow-diagram/flow-diagram-service.integration.test.ts b/src/main/flow-diagram/flow-diagram-service.integration.test.ts index 3cdbcec0..614b31a3 100644 --- a/src/main/flow-diagram/flow-diagram-service.integration.test.ts +++ b/src/main/flow-diagram/flow-diagram-service.integration.test.ts @@ -9,7 +9,7 @@ import { type FlowDiagramService, } from './flow-diagram-service'; import type { ExcalidrawScene } from './flow-diagram-scene'; -import { writeFile as writeProjectFile } from '../services/file-system'; +import { createFlowDiagramDocumentStore } from './flow-diagram-document-store'; function scene(elements: Array> = []): ExcalidrawScene { return { @@ -430,7 +430,7 @@ describe('FlowDiagramService integration', () => { const revisionGate = new Promise((resolve) => { releaseRevision = resolve; }); - const coordinatedService = createFlowDiagramService({ + const coordinatedStore = createFlowDiagramDocumentStore({ projectPath, stateRoot, revisionStore: { @@ -443,6 +443,14 @@ describe('FlowDiagramService integration', () => { consumeLatest: vi.fn(), }, }); + const coordinatedService = createFlowDiagramService({ + projectPath, + stateRoot, + documentStore: coordinatedStore, + }); + const initial = await coordinatedStore.readDocument(filePath); + expect(initial.ok).toBe(true); + if (!initial.ok) throw new Error('expected readable document'); const agentEdit = coordinatedService.execute({ action: 'edit', @@ -450,16 +458,19 @@ describe('FlowDiagramService integration', () => { operations: [{ op: 'add', elements: [rectangle('agent')] }], }); await revisionStartedPromise; - const staleAutosave = writeProjectFile( - projectPath, + const documentStore = createFlowDiagramDocumentStore({ projectPath }); + const staleAutosave = documentStore.saveDocument( filePath, original.toString('utf8'), - original.toString('utf8'), + initial.document.version, ); releaseRevision(); await expect(agentEdit).resolves.toMatchObject({ ok: true }); - await expect(staleAutosave).rejects.toMatchObject({ code: 'ECONFLICT' }); + await expect(staleAutosave).resolves.toMatchObject({ + ok: false, + error: { code: 'SOURCE_CHANGED' }, + }); expect(fs.readFileSync(filePath, 'utf8')).toContain('"id": "agent"'); }); @@ -508,7 +519,7 @@ describe('FlowDiagramService integration', () => { writeScene(filePath, scene([rectangle('original')])); const external = Buffer.from(`${JSON.stringify(scene([rectangle('external')]), null, 2)}\n`); const consumeLatest = vi.fn(async () => undefined); - const guardedService = createFlowDiagramService({ + const guardedStore = createFlowDiagramDocumentStore({ projectPath, stateRoot, revisionStore: { @@ -520,6 +531,11 @@ describe('FlowDiagramService integration', () => { consumeLatest, }, }); + const guardedService = createFlowDiagramService({ + projectPath, + stateRoot, + documentStore: guardedStore, + }); expect(await guardedService.execute({ action: 'edit', @@ -536,7 +552,7 @@ describe('FlowDiagramService integration', () => { const external = Buffer.from(`${JSON.stringify(scene([rectangle('external')]), null, 2)}\n`); const previous = Buffer.from(`${JSON.stringify(scene([rectangle('previous')]), null, 2)}\n`); const consumeLatest = vi.fn(); - const guardedService = createFlowDiagramService({ + const guardedStore = createFlowDiagramDocumentStore({ projectPath, stateRoot, revisionStore: { @@ -552,6 +568,11 @@ describe('FlowDiagramService integration', () => { consumeLatest, }, }); + const guardedService = createFlowDiagramService({ + projectPath, + stateRoot, + documentStore: guardedStore, + }); expect(await guardedService.execute({ action: 'rollback', @@ -565,7 +586,7 @@ describe('FlowDiagramService integration', () => { const filePath = path.join(projectPath, 'diagram.excalidraw'); const original = writeScene(filePath, scene([rectangle('one')])); - const revisionFailure = createFlowDiagramService({ + const revisionFailureStore = createFlowDiagramDocumentStore({ projectPath, stateRoot, revisionStore: { @@ -574,6 +595,11 @@ describe('FlowDiagramService integration', () => { consumeLatest: vi.fn(), }, }); + const revisionFailure = createFlowDiagramService({ + projectPath, + stateRoot, + documentStore: revisionFailureStore, + }); expect(await revisionFailure.execute({ action: 'edit', file_path: filePath, @@ -581,10 +607,15 @@ describe('FlowDiagramService integration', () => { })).toMatchObject({ ok: false, error: { code: 'REVISION_FAILED' } }); expect(fs.readFileSync(filePath)).toEqual(original); + const replacementFailureStore = createFlowDiagramDocumentStore({ + projectPath, + stateRoot, + beforePublish: vi.fn(async () => { throw new Error('replace interrupted'); }), + }); const replacementFailure = createFlowDiagramService({ projectPath, stateRoot, - replaceFile: vi.fn(async () => { throw new Error('replace interrupted'); }), + documentStore: replacementFailureStore, }); expect(await replacementFailure.execute({ action: 'edit', @@ -698,11 +729,16 @@ describe('FlowDiagramService integration', () => { })), consumeLatest: vi.fn(async () => { throw new Error('manifest write failed'); }), }; - const rollbackService = createFlowDiagramService({ + const rollbackDocumentStore = createFlowDiagramDocumentStore({ projectPath, stateRoot, revisionStore: failingStore, }); + const rollbackService = createFlowDiagramService({ + projectPath, + stateRoot, + documentStore: rollbackDocumentStore, + }); expect(await rollbackService.execute({ action: 'rollback', file_path: filePath })).toMatchObject({ ok: false, diff --git a/src/main/flow-diagram/flow-diagram-service.ts b/src/main/flow-diagram/flow-diagram-service.ts index 461108ca..932b2d0b 100644 --- a/src/main/flow-diagram/flow-diagram-service.ts +++ b/src/main/flow-diagram/flow-diagram-service.ts @@ -1,6 +1,9 @@ -import crypto from 'crypto'; -import fs from 'fs'; import path from 'path'; +import { + createFlowDiagramDocumentStore, + type FlowDiagramDocumentStore, + FlowDiagramOperationError, +} from './flow-diagram-document-store'; import { createFlowDiagramScene, EXCALIDRAW_SDK_VERSION, @@ -17,11 +20,7 @@ import { type FlowDiagramExportArtifact, type FlowDiagramExportFormat, } from './flow-diagram-export-renderer'; -import { - createFlowDiagramRevisionStore, - type FlowDiagramRevisionStore, -} from './flow-diagram-revision-store'; -import { runProjectFileMutation } from '../services/project-file-mutation'; +import type { FlowDiagramDocumentChangeEvent } from '../../shared/flow-diagrams'; export type FlowDiagramEditOperation = | { op: 'add'; elements: Array> } @@ -79,97 +78,13 @@ export interface FlowDiagramService { export interface CreateFlowDiagramServiceOptions { projectPath: string; stateRoot: string; - revisionStore?: FlowDiagramRevisionStore; - replaceFile?: (filePath: string, bytes: Buffer) => Promise; - replaceFileIfUnchanged?: ( - filePath: string, - bytes: Buffer, - expectedBytes: Buffer | null, - ) => Promise; + documentStore?: FlowDiagramDocumentStore; renderExport?: ( scene: ExcalidrawScene, format: FlowDiagramExportFormat, ) => Promise; notifyFileChange?: (filePath: string) => void; -} - -class FlowDiagramOperationError extends Error { - constructor( - public readonly code: string, - message: string, - ) { - super(message); - this.name = 'FlowDiagramOperationError'; - } -} - -const PROTECTED_SEGMENTS = new Set(['.git', '.cdf', 'node_modules', 'out', 'dist']); - -function isWithin(rootPath: string, candidatePath: string): boolean { - const relative = path.relative(rootPath, candidatePath); - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); -} - -function nearestExistingAncestor(candidatePath: string): string { - let current = candidatePath; - while (!fs.existsSync(current)) { - const parent = path.dirname(current); - if (parent === current) break; - current = parent; - } - return current; -} - -function resolveProjectOwnedPath( - projectPath: string, - requestedPath: string, - expectedExtension: string, -): string { - const trimmed = requestedPath.trim(); - if (!trimmed) { - throw new FlowDiagramOperationError('PATH_REQUIRED', 'A non-empty Project path is required.'); - } - if (trimmed.startsWith('~')) { - throw new FlowDiagramOperationError('PATH_OUTSIDE_PROJECT', 'Home-relative paths are not allowed.'); - } - const resolvedProjectPath = fs.realpathSync(projectPath); - const target = path.resolve(projectPath, trimmed); - if (!isWithin(path.resolve(projectPath), target)) { - throw new FlowDiagramOperationError( - 'PATH_OUTSIDE_PROJECT', - 'The requested path is outside the current Project.', - ); - } - const relative = path.relative(projectPath, target); - const segments = relative.toLowerCase().split(path.sep).filter(Boolean); - if ( - segments.some((segment) => PROTECTED_SEGMENTS.has(segment)) - || segments.some((segment) => segment === '.env' || segment.startsWith('.env.')) - ) { - throw new FlowDiagramOperationError( - 'PROTECTED_PATH', - 'The requested path is protected and cannot be used for a Flow Diagram.', - ); - } - if (path.extname(target).toLowerCase() !== expectedExtension) { - throw new FlowDiagramOperationError( - 'INVALID_EXTENSION', - `The requested path must end with ${expectedExtension}.`, - ); - } - - const ancestor = nearestExistingAncestor(target); - const realAncestor = fs.realpathSync(ancestor); - if (!isWithin(resolvedProjectPath, realAncestor)) { - throw new FlowDiagramOperationError( - 'PATH_OUTSIDE_PROJECT', - 'The requested path resolves outside the current Project.', - ); - } - if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) { - throw new FlowDiagramOperationError('SYMLINK_NOT_ALLOWED', 'Flow Diagram paths cannot be symlinks.'); - } - return target; + notifyDocumentChange?: (event: FlowDiagramDocumentChangeEvent) => void; } function safeName(value: string | undefined): string { @@ -183,20 +98,6 @@ function safeName(value: string | undefined): string { return normalized || 'flow-diagram'; } -function collisionSafePath(basePath: string): string { - if (!fs.existsSync(basePath)) return basePath; - const extension = path.extname(basePath); - const stem = basePath.slice(0, -extension.length); - for (let suffix = 2; suffix < 10_000; suffix += 1) { - const candidate = `${stem}-${suffix}${extension}`; - if (!fs.existsSync(candidate)) return candidate; - } - throw new FlowDiagramOperationError( - 'PATH_COLLISION', - 'Could not allocate a collision-safe Flow Diagram path.', - ); -} - function artifactFor(projectPath: string, filePath: string): FlowDiagramArtifactDisplay { const relativePath = path.relative(projectPath, filePath).replace(/\\/g, '/'); const title = path.basename(filePath, '.excalidraw'); @@ -217,120 +118,6 @@ function fileData(projectPath: string, filePath: string): Record { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const temporaryPath = temporaryPathFor(filePath); - try { - const handle = await fs.promises.open(temporaryPath, 'wx', 0o600); - try { - await handle.writeFile(bytes); - await handle.sync(); - } finally { - await handle.close(); - } - - if (expectedBytes === null) { - try { - await fs.promises.link(temporaryPath, filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed before the operation could be applied.', - ); - } - throw error; - } - return; - } - - let currentBytes: Buffer; - try { - currentBytes = fs.readFileSync(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed before the operation could be applied.', - ); - } - throw error; - } - if (!currentBytes.equals(expectedBytes)) { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed before the operation could be applied.', - ); - } - // CDF mutations for this Project hold the shared coordinator lock. rename is - // the single atomic publication step, so readers never observe partial bytes. - await fs.promises.rename(temporaryPath, filePath); - } finally { - await fs.promises.rm(temporaryPath, { force: true }); - } -} - -async function removeFileAtomicallyIfUnchanged( - filePath: string, - expectedBytes: Buffer, -): Promise { - let currentBytes: Buffer; - try { - currentBytes = fs.readFileSync(filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed before the operation could be completed.', - ); - } - throw error; - } - if (!currentBytes.equals(expectedBytes)) { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed before the operation could be completed.', - ); - } - await fs.promises.unlink(filePath); -} - -async function writeNewFileAtomically(filePath: string, bytes: Buffer): Promise { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const temporaryPath = temporaryPathFor(filePath); - try { - await fs.promises.writeFile(temporaryPath, bytes, { flag: 'wx', mode: 0o600 }); - try { - await fs.promises.link(temporaryPath, filePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'EEXIST') { - throw new FlowDiagramOperationError( - 'FILE_EXISTS', - 'The requested output already exists; no file was overwritten.', - ); - } - throw error; - } - } finally { - await fs.promises.rm(temporaryPath, { force: true }); - } -} - function cloneScene(scene: ExcalidrawScene): ExcalidrawScene { return structuredClone(scene); } @@ -573,52 +360,39 @@ export function createFlowDiagramService( options: CreateFlowDiagramServiceOptions, ): FlowDiagramService { const projectPath = path.resolve(options.projectPath); - const revisionStore = options.revisionStore - ?? createFlowDiagramRevisionStore(projectPath, options.stateRoot); - const replaceFileIfUnchanged = options.replaceFileIfUnchanged ?? ( - options.replaceFile - ? async (filePath: string, bytes: Buffer, expectedBytes: Buffer | null) => { - if ( - (expectedBytes === null && fs.existsSync(filePath)) - || (expectedBytes !== null && ( - !fs.existsSync(filePath) || !fs.readFileSync(filePath).equals(expectedBytes) - )) - ) { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed before the operation could be applied.', - ); - } - await options.replaceFile!(filePath, bytes); - } - : replaceFileAtomicallyIfUnchanged - ); + const documentStore = options.documentStore ?? createFlowDiagramDocumentStore({ + projectPath, + stateRoot: options.stateRoot, + notifyFileChange: options.notifyFileChange, + notifyDocumentChange: options.notifyDocumentChange, + }); const renderExport = options.renderExport ?? renderFlowDiagramExport; - const notify = (filePath: string) => options.notifyFileChange?.(filePath); - const executeUnlocked = async (input: FlowDiagramActionInput): Promise => { + const execute = async (input: FlowDiagramActionInput): Promise => { const action = input.action; if (action === 'read_format') return success(action, formatDescription()); if (action === 'create') { try { - const target = input.file_path - ? resolveProjectOwnedPath(projectPath, input.file_path, '.excalidraw') - : collisionSafePath(resolveProjectOwnedPath( - projectPath, - path.join('diagrams', `${safeName(input.name)}.excalidraw`), - '.excalidraw', - )); - if (input.file_path && fs.existsSync(target)) { - throw new FlowDiagramOperationError( - 'FILE_EXISTS', - 'The requested Flow Diagram already exists; no file was overwritten.', + const created = createFlowDiagramScene(input.elements ?? []); + const requestedPath = input.file_path + ?? path.join('diagrams', `${safeName(input.name)}.excalidraw`); + const result = await documentStore.createDocument( + requestedPath, + serializeFlowDiagramScene(created).toString('utf-8'), + { collisionSafe: !input.file_path }, + ); + if (!result.ok) { + return failure( + action, + new FlowDiagramOperationError(result.error.code, result.error.message), + 'CREATE_FAILED', ); } - const created = createFlowDiagramScene(input.elements ?? []); - await writeNewFileAtomically(target, serializeFlowDiagramScene(created)); - notify(target); - return success(action, { ...fileData(projectPath, target), scene: created }); + return success(action, { + ...fileData(projectPath, result.filePath), + scene: created, + }); } catch (error) { return failure(action, error, 'CREATE_FAILED'); } @@ -635,156 +409,70 @@ export function createFlowDiagramService( ); } - let target: string; - try { - target = resolveProjectOwnedPath(projectPath, input.file_path, '.excalidraw'); - if ( - action !== 'rollback' - && (!fs.existsSync(target) || !fs.statSync(target).isFile()) - ) { - throw new FlowDiagramOperationError('SOURCE_NOT_FOUND', 'The Flow Diagram source file does not exist.'); + if (action === 'rollback') { + const result = await documentStore.rollbackDocument(input.file_path); + if (!result.ok) { + return failure( + action, + new FlowDiagramOperationError(result.error.code, result.error.message), + 'ROLLBACK_FAILED', + ); } + return success(action, fileData(projectPath, result.filePath)); + } + + const currentResult = await documentStore.readDocument(input.file_path); + if (!currentResult.ok) { + return failure( + action, + new FlowDiagramOperationError( + currentResult.error.code, + currentResult.error.message, + ), + action === 'export' ? 'EXPORT_FAILED' : 'READ_FAILED', + ); + } + const target = path.resolve(projectPath, input.file_path); + let current: ExcalidrawScene; + try { + current = parseFlowDiagramScene(currentResult.document.content); } catch (error) { - return failure(action, error, 'SOURCE_NOT_FOUND'); + return failure(action, error, 'INVALID_SCENE'); } if (action === 'get') { - try { - const current = parseFlowDiagramScene(fs.readFileSync(target)); - return success(action, { ...fileData(projectPath, target), scene: current }); - } catch (error) { - return failure(action, error, 'READ_FAILED'); - } + return success(action, { ...fileData(projectPath, target), scene: current }); } if (action === 'edit') { - const originalBytes = fs.readFileSync(target); - let original: ExcalidrawScene; - try { - original = parseFlowDiagramScene(originalBytes); - } catch (error) { - return failure(action, error, 'INVALID_SCENE'); - } let candidate: ReturnType; try { - candidate = applyOperations(original, input.operations ?? []); + candidate = applyOperations(current, input.operations ?? []); } catch (error) { return failure(action, error, 'INVALID_OPERATION'); } - const candidateBytes = serializeFlowDiagramScene(candidate.scene); - let revisionToken: string; - try { - revisionToken = await revisionStore.record(target, originalBytes, candidateBytes); - } catch (error) { + const result = await documentStore.applyAgentEdit( + input.file_path, + serializeFlowDiagramScene(candidate.scene).toString('utf-8'), + currentResult.document.version, + ); + if (!result.ok) { return failure( action, - new FlowDiagramOperationError('REVISION_FAILED', safeMessage(error)), - 'REVISION_FAILED', + new FlowDiagramOperationError(result.error.code, result.error.message), + 'WRITE_FAILED', ); } - try { - await replaceFileIfUnchanged(target, candidateBytes, originalBytes); - notify(target); - return success(action, { - ...fileData(projectPath, target), - summary: candidate.summary, - }); - } catch (error) { - try { - await replaceFileIfUnchanged(target, originalBytes, candidateBytes); - } catch { - // A concurrent source is preserved; default replacement never exposes partial bytes. - } - try { - await revisionStore.consumeLatest(target, revisionToken); - } catch (cleanupError) { - return failure( - action, - new FlowDiagramOperationError('REVISION_FAILED', safeMessage(cleanupError)), - 'REVISION_FAILED', - ); - } - return error instanceof FlowDiagramOperationError - ? failure(action, error, 'WRITE_FAILED') - : failure( - action, - new FlowDiagramOperationError('WRITE_FAILED', safeMessage(error)), - 'WRITE_FAILED', - ); - } - } - - if (action === 'rollback') { - const currentBytes = fs.existsSync(target) ? fs.readFileSync(target) : null; - let rollbackBytes: Buffer | null = null; - try { - const revision = await revisionStore.peekLatest(target); - if (!revision) { - throw new FlowDiagramOperationError( - 'NO_REVISION', - 'No applicable Agent edit revision is available for this Flow Diagram.', - ); - } - parseFlowDiagramScene(revision.sourceBytes); - if (!currentBytes || hashBytes(currentBytes) !== revision.appliedSourceHash) { - throw new FlowDiagramOperationError( - 'SOURCE_CHANGED', - 'The Flow Diagram changed after the latest Agent edit; rollback was not applied.', - ); - } - rollbackBytes = revision.sourceBytes; - await replaceFileIfUnchanged(target, revision.sourceBytes, currentBytes); - await revisionStore.consumeLatest(target, revision.token); - notify(target); - return success(action, fileData(projectPath, target)); - } catch (error) { - if (rollbackBytes) { - try { - if (currentBytes) { - await replaceFileIfUnchanged(target, currentBytes, rollbackBytes); - } else { - await removeFileAtomicallyIfUnchanged(target, rollbackBytes); - } - } catch (restoreError) { - if (!(restoreError instanceof FlowDiagramOperationError && restoreError.code === 'SOURCE_CHANGED')) { - return failure( - action, - new FlowDiagramOperationError( - 'ROLLBACK_RESTORE_FAILED', - 'Rollback failed and the previous source could not be restored.', - ), - 'ROLLBACK_RESTORE_FAILED', - ); - } - } - } - return failure(action, error, 'ROLLBACK_FAILED'); - } + return success(action, { + ...fileData(projectPath, result.filePath), + summary: candidate.summary, + }); } try { - const currentBytes = fs.readFileSync(target); - const current = parseFlowDiagramScene(currentBytes); if (input.format !== 'svg' && input.format !== 'png') { throw new FlowDiagramOperationError('FORMAT_REQUIRED', 'Export format must be png or svg.'); } - const expectedExtension = `.${input.format}`; - const requestedOutput = input.output_path - ? resolveProjectOwnedPath(projectPath, input.output_path, expectedExtension) - : collisionSafePath(resolveProjectOwnedPath( - projectPath, - path.join( - path.dirname(path.relative(projectPath, target)), - `${path.basename(target, '.excalidraw')}${expectedExtension}`, - ), - expectedExtension, - )); - if (input.output_path && fs.existsSync(requestedOutput)) { - throw new FlowDiagramOperationError( - 'FILE_EXISTS', - 'The requested export already exists; no file was overwritten.', - ); - } let artifact: FlowDiagramExportArtifact; try { artifact = await renderExport(current, input.format); @@ -794,21 +482,29 @@ export function createFlowDiagramService( 'The Flow Diagram could not be rendered for export.', ); } - await writeNewFileAtomically(requestedOutput, artifact.bytes); - notify(requestedOutput); - const relativePath = path.relative(projectPath, requestedOutput).replace(/\\/g, '/'); + const writeResult = await documentStore.createExport({ + sourceFilePath: input.file_path, + requestedOutputPath: input.output_path, + format: input.format, + bytes: artifact.bytes, + }); + if (!writeResult.ok) { + throw new FlowDiagramOperationError( + writeResult.error.code, + writeResult.error.message, + ); + } + const relativePath = path.relative(projectPath, writeResult.filePath).replace(/\\/g, '/'); return success(action, { - filePath: requestedOutput, + filePath: writeResult.filePath, relativePath, mimeType: artifact.mimeType, artifact: { kind: 'file', - path: requestedOutput, - title: path.basename(requestedOutput), + path: writeResult.filePath, + title: path.basename(writeResult.filePath), mimeType: artifact.mimeType, - displayMarkdown: input.format === 'svg' || input.format === 'png' - ? `![${path.basename(requestedOutput)}](${requestedOutput})` - : `[${path.basename(requestedOutput)}](${requestedOutput})`, + displayMarkdown: `![${path.basename(writeResult.filePath)}](${writeResult.filePath})`, }, }); } catch (error) { @@ -817,9 +513,6 @@ export function createFlowDiagramService( }; return { - execute: (input) => runProjectFileMutation( - projectPath, - () => executeUnlocked(input), - ), + execute, }; } diff --git a/src/main/flow-diagram/manage-flow-diagram-tool.ts b/src/main/flow-diagram/manage-flow-diagram-tool.ts index 7504174c..edaf79aa 100644 --- a/src/main/flow-diagram/manage-flow-diagram-tool.ts +++ b/src/main/flow-diagram/manage-flow-diagram-tool.ts @@ -2,7 +2,10 @@ import { app } from 'electron'; import path from 'path'; import { tool } from '@langchain/core/tools'; import { z } from 'zod'; -import { notifyFileChange } from '../services/file-watcher'; +import { + notifyFileChange, + notifyFlowDiagramDocumentChange, +} from '../services/file-watcher'; import { createFlowDiagramService, type FlowDiagramService, @@ -76,6 +79,7 @@ export function createManageFlowDiagramTool( projectPath, stateRoot: options.stateRoot ?? path.join(app.getPath('userData'), 'flow-diagrams'), notifyFileChange, + notifyDocumentChange: notifyFlowDiagramDocumentChange, renderExport: renderFlowDiagramExportAdapter, }); diff --git a/src/main/global-skill-scene-exposure.ts b/src/main/global-skill-scene-exposure.ts index efa7c507..c01a4b4a 100644 --- a/src/main/global-skill-scene-exposure.ts +++ b/src/main/global-skill-scene-exposure.ts @@ -1,6 +1,6 @@ import store from './store'; import { createSceneSkillExposureService } from './scene-skill-exposure'; -import { getBuiltInSkillRegistrations } from './deepagent/skill-manager'; +import { getBuiltInSkillRegistrations } from './deepagent/skill-catalog'; import type { ProjectScene } from '../shared/types'; /** Resolves whether a Global Skill is exposed in a Project Scene. */ diff --git a/src/main/index.ts b/src/main/index.ts index db2516f4..f82ab6af 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -18,7 +18,7 @@ import { conversationRunStreams } from './conversation-run-stream-runtime'; import { createCapabilityJobContinuationRunner } from './capabilities/capability-job-continuation-runner'; import { hardenWindowNavigation } from './window-navigation-guard'; import { conversationWorkingStateLifecycle } from './deepagent/conversation-working-state'; -import { ConversationWorkingStateWorkerRunner } from './deepagent/conversation-working-state-worker-runner'; +import { createConversationWorkingStateReconciliationRunner } from './deepagent/conversation-working-state-reconciliation'; // Register cdf-file scheme as privileged to bypass CSP and security sandboxing for local image media. // standard:true additionally enables Chromium's media seeking/range machinery (see cdf-file-protocol.ts). @@ -39,9 +39,7 @@ import path from 'path'; let mainWindow: BrowserWindow | null = null; let isQuitting = false; -const workingStateReconciliationRunner = new ConversationWorkingStateWorkerRunner( - () => path.join(__dirname, 'conversation-working-state-reconciliation-worker.js') -); +const workingStateReconciliationRunner = createConversationWorkingStateReconciliationRunner(); function reconcileConversationWorkingStateAtStartup() { return conversationWorkingStateLifecycle.reconcileOrphansAtStartup( diff --git a/src/main/ipc-handlers.test.ts b/src/main/ipc-handlers.test.ts index b7f3bca9..69ee9936 100644 --- a/src/main/ipc-handlers.test.ts +++ b/src/main/ipc-handlers.test.ts @@ -29,6 +29,7 @@ const { deleteProjectMock, compactWorkingStateMock, getWorkingStateStorageStatusMock, + getMaintenanceBlockerMock, captureConversationSystemContextSnapshotMock, createAgentCatalogMock, agentCatalogMock, @@ -82,6 +83,7 @@ const { deleteProjectMock: vi.fn(), compactWorkingStateMock: vi.fn(), getWorkingStateStorageStatusMock: vi.fn(), + getMaintenanceBlockerMock: vi.fn(), captureConversationSystemContextSnapshotMock: vi.fn(() => ({ promptSnapshot: 'Captured Master prompt', skillSnapshot: listResolvedSkillViewsMock(), @@ -154,9 +156,27 @@ vi.mock('./conversation-run-stream-runtime', () => ({ }, })); -vi.mock('./deepagent/conversation-working-state-maintenance', () => ({ - compactConversationWorkingState: compactWorkingStateMock, - getConversationWorkingStateStorageStatus: getWorkingStateStorageStatusMock, +vi.mock('./deepagent/conversation-working-state', () => ({ + DEEPAGENT_CHECKPOINT_NAMESPACE: '', + conversationWorkingStateLifecycle: { + getStorageStatus: getWorkingStateStorageStatusMock, + getMaintenanceBlocker: getMaintenanceBlockerMock, + compact: compactWorkingStateMock, + beginRuntimeUse: vi.fn(() => () => {}), + beginCapabilityJobUse: vi.fn(() => () => {}), + acquireSaver: vi.fn(), + enterMaintenance: vi.fn(), + leaveMaintenance: vi.fn(), + deleteThread: vi.fn(), + assertConversationDeletionAllowed: vi.fn(), + reconcileOrphansAtStartup: vi.fn(), + close: vi.fn(), + }, +})); + +vi.mock('./deepagent/conversation-working-state-compaction', () => ({ + findConversationWorkingStateMaintenanceBlocker: vi.fn(() => null), + createConversationWorkingStateCompactionRunner: vi.fn(() => ({ run: vi.fn() })), })); vi.mock('./security', () => ({ @@ -258,6 +278,7 @@ describe('IPC handlers', () => { deleteConversationMock.mockReset(); deleteProjectMock.mockReset(); compactWorkingStateMock.mockReset(); + getMaintenanceBlockerMock.mockReset().mockReturnValue(null); getWorkingStateStorageStatusMock.mockReset(); getWorkingStateStorageStatusMock.mockReturnValue({ phase: 'normal', @@ -391,7 +412,11 @@ describe('IPC handlers', () => { const result = await handler({}, { skipIdleChecks: true, databasePath: '/private/cdf.db' }); - expect(compactWorkingStateMock).toHaveBeenCalledWith(); + expect(compactWorkingStateMock).toHaveBeenCalledWith( + expect.any(Function), + expect.any(Function), + expect.anything(), + ); expect(getWorkingStateStorageStatusMock).toHaveBeenCalledOnce(); expect(result).toEqual(status); expect(JSON.stringify(result)).not.toMatch(/path|sql|table|checkpoint|thread|database/i); diff --git a/src/main/ipc-handlers.ts b/src/main/ipc-handlers.ts index 22b9e369..52648fe9 100644 --- a/src/main/ipc-handlers.ts +++ b/src/main/ipc-handlers.ts @@ -19,7 +19,13 @@ import fs from 'fs'; import os from 'os'; import path from 'path'; import { readDirectory, readFile, getFileInfo, writeFile, createFile, createDirectory, renameEntry, trashEntry, resolveProjectFile } from './services/file-system'; -import { ensureFileWatcher, notifyFileChange, watchDirectory, unwatchDirectory } from './services/file-watcher'; +import { + ensureFileWatcher, + notifyFileChange, + notifyFlowDiagramDocumentChange, + watchDirectory, + unwatchDirectory, +} from './services/file-watcher'; import { listPhysicalSkills, listResolvedSkillViews, @@ -28,7 +34,7 @@ import { deletePhysicalSkill, importPhysicalSkillDirectory, getBuiltInSkillRegistrations, -} from './deepagent/skill-manager'; +} from './deepagent/skill-catalog'; import type { GlobalSkillReference, SceneSkillExposureInput } from '../shared/skills'; import { isRegisteredSceneId, SCENE_REGISTRY } from '../shared/scenes'; import { createSceneSkillExposureService } from './scene-skill-exposure'; @@ -85,10 +91,11 @@ import { conversationRunStreams } from './conversation-run-stream-runtime'; import { deleteConversation, deleteProject } from './conversation-deletion'; import { conversationWorkingStateLifecycle } from './deepagent/conversation-working-state'; import { registerFlowDiagramExportResponseHandler } from './flow-diagram/flow-diagram-export-adapter'; +import { createFlowDiagramDocumentStore } from './flow-diagram/flow-diagram-document-store'; import { - compactConversationWorkingState, - getConversationWorkingStateStorageStatus, -} from './deepagent/conversation-working-state-maintenance'; + createConversationWorkingStateCompactionRunner, + findConversationWorkingStateMaintenanceBlocker, +} from './deepagent/conversation-working-state-compaction'; import { DelegatedAgentRunRepository } from './deepagent/delegated-agent-run-repository'; import { createAgentCatalog, type CatalogAgent } from './agent-catalog'; @@ -281,12 +288,34 @@ export function registerIpcHandlers() { typedHandle('conversation:get-active-run', (_event, sessionId) => conversationRunStreams.getActive(sessionId) ); - typedHandle('working-state:get-storage-status', () => - getConversationWorkingStateStorageStatus() - ); + // Conversation Working State maintenance binding: the lifecycle stays free of + // the business database, so the blocker/live-id readers are bound here. + const workingStateCompactionRunner = createConversationWorkingStateCompactionRunner(); + const readWorkingStateMaintenanceBlocker = () => + findConversationWorkingStateMaintenanceBlocker(db); + const readLiveConversationIds = () => + (db.prepare('SELECT id FROM sessions').all() as Array<{ id: string }>) + .map((session) => session.id); + const getWorkingStateStorageStatus = () => { + const status = conversationWorkingStateLifecycle.getStorageStatus(); + if (status.phase === 'analyzing' || status.phase === 'optimizing') { + return status; + } + return { + ...status, + blockedReason: conversationWorkingStateLifecycle.getMaintenanceBlocker( + readWorkingStateMaintenanceBlocker + ), + }; + }; + typedHandle('working-state:get-storage-status', () => getWorkingStateStorageStatus()); typedHandle('working-state:optimize-storage', async () => { - await compactConversationWorkingState(); - return getConversationWorkingStateStorageStatus(); + await conversationWorkingStateLifecycle.compact( + readWorkingStateMaintenanceBlocker, + readLiveConversationIds, + workingStateCompactionRunner + ); + return getWorkingStateStorageStatus(); }); const ensureProjectForSession = (projectId: string) => { const existing = db.prepare('SELECT id FROM projects WHERE id = ?').get(projectId); @@ -1225,12 +1254,12 @@ export function registerIpcHandlers() { } }); - typedHandle('fs:writeFile', async (_, rootPath, filePath, content, expectedContent) => { + typedHandle('fs:writeFile', async (_, rootPath, filePath, content) => { if (!isRegisteredProjectRoot(rootPath)) { return { ok: false, error: { code: 'EACCES', message: 'rootPath is not a registered project root' } }; } try { - await writeFile(rootPath, filePath, content, expectedContent); + await writeFile(rootPath, filePath, content); notifyFileChange(filePath); return { ok: true }; } catch (err: any) { @@ -1238,6 +1267,34 @@ export function registerIpcHandlers() { } }); + typedHandle('flow-diagram:load-document', async (_, rootPath, filePath) => { + if (!isRegisteredProjectRoot(rootPath)) { + return { ok: false, error: { code: 'EACCES', message: 'rootPath is not a registered project root' } }; + } + const documentStore = createFlowDiagramDocumentStore({ projectPath: rootPath }); + return documentStore.readDocument(filePath); + }); + + typedHandle( + 'flow-diagram:save-document', + async (_, rootPath, filePath, content, expectedVersion, mutationId) => { + if (!isRegisteredProjectRoot(rootPath)) { + return { ok: false, error: { code: 'EACCES', message: 'rootPath is not a registered project root' } }; + } + const documentStore = createFlowDiagramDocumentStore({ + projectPath: rootPath, + notifyFileChange, + notifyDocumentChange: notifyFlowDiagramDocumentChange, + }); + return documentStore.saveDocument( + filePath, + content, + expectedVersion, + mutationId, + ); + }, + ); + typedHandle('fs:createFile', async (_, rootPath, filePath) => { if (!isRegisteredProjectRoot(rootPath)) { return { ok: false, error: { code: 'EACCES', message: 'rootPath is not a registered project root' } }; diff --git a/src/main/services/file-system.test.ts b/src/main/services/file-system.test.ts index a1f15e5e..00cdc094 100644 --- a/src/main/services/file-system.test.ts +++ b/src/main/services/file-system.test.ts @@ -138,14 +138,6 @@ describe('FileSystemService', () => { expect(fs.readFileSync(filePath, 'utf-8')).toBe('hello'); }); - it('rejects a stale compare-and-swap save without overwriting disk content', async () => { - const filePath = path.join(tmpDir, 'hello.ts'); - await expect(writeFile(tmpDir, filePath, 'stale edit', 'older content')).rejects.toMatchObject({ - code: 'ECONFLICT', - }); - expect(fs.readFileSync(filePath, 'utf-8')).toBe('console.log("hello");'); - }); - it('rejects path traversal', async () => { await expect(writeFile(tmpDir, '/etc/passwd', 'hack')).rejects.toThrow(); }); diff --git a/src/main/services/file-system.ts b/src/main/services/file-system.ts index af6d694f..0ffac9d6 100644 --- a/src/main/services/file-system.ts +++ b/src/main/services/file-system.ts @@ -118,22 +118,12 @@ export async function writeFile( rootPath: string, filePath: string, content: string, - expectedContent?: string, ): Promise { await runProjectFileMutation(rootPath, async () => { const resolved = resolveProjectFile(rootPath, filePath); if (isProtectedPath(resolved)) { throw new Error(`Cannot write to protected path: ${filePath}`); } - if (expectedContent !== undefined) { - const currentContent = await fsp.readFile(resolved, 'utf-8'); - if (currentContent !== expectedContent) { - throw Object.assign( - new Error('File changed on disk before this save could be applied.'), - { code: 'ECONFLICT' }, - ); - } - } await fsp.writeFile(resolved, content, 'utf-8'); }); } diff --git a/src/main/services/file-watcher.test.ts b/src/main/services/file-watcher.test.ts new file mode 100644 index 00000000..cc7b0cdf --- /dev/null +++ b/src/main/services/file-watcher.test.ts @@ -0,0 +1,45 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { send } = vi.hoisted(() => ({ send: vi.fn() })); + +vi.mock('electron', () => ({ + BrowserWindow: { + getAllWindows: () => [{ webContents: { send } }], + }, +})); + +import { notifyFileChange } from './file-watcher'; + +describe('file-watcher Flow Diagram notifications', () => { + let directory: string; + + beforeEach(() => { + vi.useFakeTimers(); + send.mockClear(); + directory = fs.mkdtempSync(path.join(os.tmpdir(), 'cdf-flow-watcher-')); + }); + + afterEach(() => { + vi.useRealTimers(); + fs.rmSync(directory, { recursive: true, force: true }); + }); + + it('publishes the current opaque version for an external diagram change', async () => { + const filePath = path.join(directory, 'diagram.excalidraw'); + fs.writeFileSync(filePath, '{"type":"excalidraw"}'); + + notifyFileChange(filePath); + await vi.advanceTimersByTimeAsync(200); + + expect(send).toHaveBeenCalledWith( + 'flow-diagram:document-change', + { + filePath, + version: expect.stringMatching(/^[a-f0-9]{64}$/), + }, + ); + }); +}); diff --git a/src/main/services/file-watcher.ts b/src/main/services/file-watcher.ts index b403ab0a..34aaccba 100644 --- a/src/main/services/file-watcher.ts +++ b/src/main/services/file-watcher.ts @@ -1,7 +1,11 @@ import { BrowserWindow } from 'electron'; +import fs from 'fs'; +import path from 'path'; import { typedSend } from '../typed-ipc'; import chokidar from 'chokidar'; import log from '../logger'; +import type { FlowDiagramDocumentChangeEvent } from '../../shared/flow-diagrams'; +import { hashBytes } from '../flow-diagram/flow-diagram-document-store'; const watchers = new Map>(); let currentRootPath: string | null = null; @@ -18,6 +22,17 @@ const IGNORED = [ let debounceTimer: ReturnType | null = null; const pendingEvents = new Map(); +export function notifyFlowDiagramDocumentChange( + event: FlowDiagramDocumentChangeEvent, +): void { + const windows = typeof BrowserWindow?.getAllWindows === 'function' + ? BrowserWindow.getAllWindows() + : []; + windows.forEach((window) => { + typedSend(window.webContents, 'flow-diagram:document-change', event); + }); +} + function flushEvents() { for (const [filePath, type] of pendingEvents) { const windows = typeof BrowserWindow?.getAllWindows === 'function' @@ -26,6 +41,15 @@ function flushEvents() { windows.forEach((w) => { typedSend(w.webContents, 'fs:directoryChange', { type, path: filePath }); }); + if (path.extname(filePath).toLowerCase() === '.excalidraw') { + let version: FlowDiagramDocumentChangeEvent['version'] = null; + try { + version = hashBytes(fs.readFileSync(filePath)); + } catch { + version = null; + } + notifyFlowDiagramDocumentChange({ filePath, version }); + } } pendingEvents.clear(); debounceTimer = null; diff --git a/src/preload/index.test.ts b/src/preload/index.test.ts index 9cc35c1f..9dc7e4a8 100644 --- a/src/preload/index.test.ts +++ b/src/preload/index.test.ts @@ -85,6 +85,7 @@ describe('preload bridge', () => { [() => api.capabilityJobs.onChanged, 'capability-jobs:changed'], [() => api.workflowRun.onProjectionEvent, 'workflow-run:projection-event'], [() => api.fs.onDirectoryChange, 'fs:directoryChange'], + [() => api.flowDiagram.onDocumentChange, 'flow-diagram:document-change'], [() => api.commands.onChanged, 'commands:changed'], [() => api.commands.onFallback, 'commands:fallback'], ]; @@ -114,6 +115,33 @@ describe('preload bridge', () => { expect(invokeMock).toHaveBeenCalledWith('workflow-run:get-tasks', 'run-1', 'stage-2'); }); + it('exposes only the versioned Flow Diagram load and save invokes', async () => { + const api = await loadApi(); + + await api.flowDiagram.loadDocument('/project', '/project/diagram.excalidraw'); + await api.flowDiagram.saveDocument( + '/project', + '/project/diagram.excalidraw', + '{"type":"excalidraw"}', + 'version-1', + 'mutation-1', + ); + + expect(invokeMock).toHaveBeenCalledWith( + 'flow-diagram:load-document', + '/project', + '/project/diagram.excalidraw', + ); + expect(invokeMock).toHaveBeenCalledWith( + 'flow-diagram:save-document', + '/project', + '/project/diagram.excalidraw', + '{"type":"excalidraw"}', + 'version-1', + 'mutation-1', + ); + }); + it('sends flow-diagram export responses through ipcRenderer.send', async () => { const api = await loadApi(); const response = { requestId: 'x', ok: true } as unknown; diff --git a/src/preload/index.ts b/src/preload/index.ts index c3ae9747..2d32669a 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,7 @@ import type { CapabilityJobAction } from '../shared/capability-jobs'; import { FLOW_DIAGRAM_EXPORT_REQUEST_CHANNEL, FLOW_DIAGRAM_EXPORT_RESPONSE_CHANNEL, + type FlowDiagramDocumentVersion, type FlowDiagramExportRequest, type FlowDiagramExportResponse, } from '../shared/flow-diagrams'; @@ -161,6 +162,25 @@ const api = { }, }, flowDiagram: { + loadDocument: (rootPath: string, filePath: string) => + typedInvoke('flow-diagram:load-document', rootPath, filePath), + saveDocument: ( + rootPath: string, + filePath: string, + content: string, + expectedVersion: FlowDiagramDocumentVersion, + mutationId?: string, + ) => typedInvoke( + 'flow-diagram:save-document', + rootPath, + filePath, + content, + expectedVersion, + mutationId, + ), + onDocumentChange: ( + callback: (data: IpcEventPayload<'flow-diagram:document-change'>) => void, + ) => typedOn('flow-diagram:document-change', callback), onExportRequest: (callback: (request: FlowDiagramExportRequest) => void) => { const listener = (_event: IpcRendererEvent, request: FlowDiagramExportRequest) => callback(request); ipcRenderer.on(FLOW_DIAGRAM_EXPORT_REQUEST_CHANNEL, listener); @@ -180,8 +200,8 @@ const api = { typedInvoke('fs:getFileInfo', rootPath, filePath), onDirectoryChange: (callback: (data: IpcEventPayload<'fs:directoryChange'>) => void) => typedOn('fs:directoryChange', callback), - writeFile: (rootPath: string, filePath: string, content: string, expectedContent?: string) => - typedInvoke('fs:writeFile', rootPath, filePath, content, expectedContent), + writeFile: (rootPath: string, filePath: string, content: string) => + typedInvoke('fs:writeFile', rootPath, filePath, content), createFile: (rootPath: string, filePath: string) => typedInvoke('fs:createFile', rootPath, filePath), createDirectory: (rootPath: string, dirPath: string) => diff --git a/src/renderer/src/components/AgentLibrary/AgentEditDialog.test.tsx b/src/renderer/src/components/AgentLibrary/AgentEditDialog.test.tsx index ed39750f..729c3e6f 100644 --- a/src/renderer/src/components/AgentLibrary/AgentEditDialog.test.tsx +++ b/src/renderer/src/components/AgentLibrary/AgentEditDialog.test.tsx @@ -49,8 +49,8 @@ describe('AgentEditDialog', () => { fireEvent.click(screen.getByText('Manage Skill preload')); - expect(screen.getByRole('button', { name: /preload review/i })).toBeTruthy(); - expect(screen.queryByRole('button', { name: /preload project-review/i })).toBeNull(); + expect(screen.getByRole('checkbox', { name: /preload review/i })).toBeTruthy(); + expect(screen.queryByRole('checkbox', { name: /preload project-review/i })).toBeNull(); }); it('hydrates a new Custom Agent model after sources finish loading without resetting its draft', () => { @@ -115,7 +115,7 @@ describe('AgentEditDialog', () => { target: { value: 'Review Agent' }, }); fireEvent.click(screen.getByText('Manage Skill preload')); - fireEvent.click(screen.getByRole('button', { name: /preload review/i })); + fireEvent.click(screen.getByRole('checkbox', { name: /preload review/i })); fireEvent.click(screen.getByText('Save')); expect(createCustomAgent).toHaveBeenCalledWith(expect.objectContaining({ diff --git a/src/renderer/src/components/AgentLibrary/SkillPreloadSection.test.tsx b/src/renderer/src/components/AgentLibrary/SkillPreloadSection.test.tsx new file mode 100644 index 00000000..03896b9f --- /dev/null +++ b/src/renderer/src/components/AgentLibrary/SkillPreloadSection.test.tsx @@ -0,0 +1,130 @@ +import { useState } from 'react'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Skill } from '@shared/types'; +import i18n from '../../i18n'; +import { SkillPreloadSection } from './SkillPreloadSection'; + +const skills = [ + { + id: 'global:review', + name: 'review', + description: 'Review workflow', + scope: 'global', + sourceKind: 'user', + sourceLabel: 'Global Skill', + resourceFiles: [], + created_at: 0, + updated_at: 0, + }, + { + id: 'global:writer', + name: 'writer', + description: 'Writing workflow', + scope: 'global', + sourceKind: 'managed', + sourceLabel: 'Managed Skill', + resourceFiles: [], + created_at: 0, + updated_at: 0, + }, +] as Skill[]; + +function SkillPreloadHarness({ onToggle = vi.fn() }: { onToggle?: (skillId: string) => void }) { + const [selectedSkillIds, setSelectedSkillIds] = useState([]); + const handleToggle = (skillId: string) => { + setSelectedSkillIds((current) => ( + current.includes(skillId) + ? current.filter((id) => id !== skillId) + : [...current, skillId] + )); + onToggle(skillId); + }; + + return ( +
+ + +
+ ); +} + +beforeEach(async () => { + await i18n.changeLanguage('en-US'); +}); + +describe('SkillPreloadSection', () => { + it('opens from the keyboard and moves focus into the search field', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Manage Skill preload' }); + trigger.focus(); + + await user.keyboard('{Enter}'); + + expect(screen.getByPlaceholderText('Search skills...')).toBe(document.activeElement); + }); + + it('selects and clears a Skill with Enter and Space while exposing checked state', async () => { + const user = userEvent.setup(); + const onToggle = vi.fn(); + render(); + await user.click(screen.getByRole('button', { name: 'Manage Skill preload' })); + const candidate = screen.getByRole('checkbox', { name: 'Preload review' }); + candidate.focus(); + + await user.keyboard('{Enter}'); + expect(screen.getByRole('checkbox', { name: 'Preload review' }).getAttribute('aria-checked')).toBe('true'); + + await user.keyboard(' '); + expect(screen.getByRole('checkbox', { name: 'Preload review' }).getAttribute('aria-checked')).toBe('false'); + expect(onToggle).toHaveBeenNthCalledWith(1, 'global:review'); + expect(onToggle).toHaveBeenNthCalledWith(2, 'global:review'); + }); + + it('closes with Escape and restores focus to the trigger', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Manage Skill preload' }); + await user.click(trigger); + + await user.keyboard('{Escape}'); + + expect(screen.queryByPlaceholderText('Search skills...')).toBeNull(); + expect(document.activeElement).toBe(trigger); + }); + + it('closes on outside click and clears the search before reopening', async () => { + const user = userEvent.setup(); + render(); + const trigger = screen.getByRole('button', { name: 'Manage Skill preload' }); + await user.click(trigger); + await user.type(screen.getByPlaceholderText('Search skills...'), 'review'); + + await user.click(screen.getByRole('button', { name: 'Outside target' })); + expect(screen.queryByPlaceholderText('Search skills...')).toBeNull(); + + await user.click(trigger); + expect((screen.getByPlaceholderText('Search skills...') as HTMLInputElement).value).toBe(''); + }); + + it('filters candidates and reports an empty result', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole('button', { name: 'Manage Skill preload' })); + const search = screen.getByPlaceholderText('Search skills...'); + + await user.type(search, 'writer'); + expect(screen.getByRole('checkbox', { name: 'Preload writer' })).toBeTruthy(); + expect(screen.queryByRole('checkbox', { name: 'Preload review' })).toBeNull(); + + await user.clear(search); + await user.type(search, 'missing'); + expect(screen.getByText('No matching skills found')).toBeTruthy(); + }); +}); diff --git a/src/renderer/src/components/AgentLibrary/SkillPreloadSection.tsx b/src/renderer/src/components/AgentLibrary/SkillPreloadSection.tsx index 26fd0daf..e3645c50 100644 --- a/src/renderer/src/components/AgentLibrary/SkillPreloadSection.tsx +++ b/src/renderer/src/components/AgentLibrary/SkillPreloadSection.tsx @@ -1,7 +1,8 @@ -import { useEffect, useRef, useState } from 'react'; +import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Search } from 'lucide-react'; +import { Check, Plus, Search } from 'lucide-react'; import type { Skill } from '@shared/types'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; interface SkillPreloadSectionProps { skills: Skill[]; @@ -16,30 +17,19 @@ function getSkillDisplayName(skill: { name: string; qualifiedName?: string | nul /** * Skill preload field group: selected-skill chips plus a searchable dropdown of * Global Skill candidates. Owns its dropdown/search state; closing the dropdown - * (toggle or click outside) always clears the search query. + * always clears the search query. */ export function SkillPreloadSection({ skills, selectedSkillIds, onToggleSkill }: SkillPreloadSectionProps) { const { t } = useTranslation(); const [dropdownOpen, setDropdownOpenRaw] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const containerRef = useRef(null); + const searchInputRef = useRef(null); const setDropdownOpen = (open: boolean) => { setDropdownOpenRaw(open); if (!open) setSearchQuery(''); }; - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setDropdownOpenRaw(false); - setSearchQuery(''); - } - } - document.addEventListener('mousedown', handleClickOutside); - return () => document.removeEventListener('mousedown', handleClickOutside); - }, []); - const candidates = skills.filter(sk => { if (sk.scope !== 'global') return false; const query = searchQuery.toLowerCase(); @@ -52,7 +42,7 @@ export function SkillPreloadSection({ skills, selectedSkillIds, onToggleSkill }: skill.sourceLabel || (skill.scope === 'project' ? t('agent.skillSourceProject') : t('agent.skillSourceGlobal')); return ( -
+
- - - {dropdownOpen && ( -
+ + + + + { + event.preventDefault(); + searchInputRef.current?.focus(); + }} + className="w-[var(--radix-popover-trigger-width)] max-h-[220px] p-2 select-none flex flex-col gap-1" + >
setSearchQuery(e.target.value)} className="bg-transparent text-xs text-[var(--color-text-primary)] outline-none w-full py-0.5" - onClick={(e) => e.stopPropagation()} />
@@ -115,38 +112,50 @@ export function SkillPreloadSection({ skills, selectedSkillIds, onToggleSkill }: const sourceLabel = getSkillSourceLabel(sk); const isBound = selectedSkillIds.includes(sk.id); return ( -
onToggleSkill(sk.id)} - className={`flex items-center justify-between px-2.5 py-1.5 rounded-md text-xs cursor-pointer transition-colors ${ + onKeyDown={(event) => { + if (event.key === ' ') { + event.preventDefault(); + onToggleSkill(sk.id); + } + }} + className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded-md text-xs cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-accent)] ${ isBound ? 'bg-[var(--color-success-dim)]/20 text-[var(--color-success)] font-medium' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-bg-hover)] hover:text-[var(--color-text-primary)]' }`} > -
- + + {displayName} -
+ {sourceLabel} -
+ ); })} {candidates.length === 0 && (
{t('agent.noSkillMatch')}
)}
-
- )} + +
); } diff --git a/src/renderer/src/components/FilePanel/EditorPane.tsx b/src/renderer/src/components/FilePanel/EditorPane.tsx index a55fa82a..bb1750d8 100644 --- a/src/renderer/src/components/FilePanel/EditorPane.tsx +++ b/src/renderer/src/components/FilePanel/EditorPane.tsx @@ -6,6 +6,7 @@ import { useFileStore } from '../../stores/fileStore'; import { MarkdownRenderer } from '../ChatArea/MarkdownRenderer'; import { isFlowDiagramFile } from '../../lib/flowDiagramFile'; import { FileTypeIcon } from './FileTypeIcon'; +import type { FlowDiagramDocumentVersion } from '@shared/flow-diagrams'; const MonacoEditor = lazy(() => import('@monaco-editor/react')); const FlowDiagramEditor = lazy(() => @@ -35,10 +36,17 @@ interface EditorPaneProps { filePath: string; fileName: string; content: string; + documentVersion?: FlowDiagramDocumentVersion; loadError?: 'unreadable'; } -export function EditorPane({ filePath, fileName, content, loadError }: EditorPaneProps) { +export function EditorPane({ + filePath, + fileName, + content, + documentVersion, + loadError, +}: EditorPaneProps) { const language = useMemo(() => detectLanguage(fileName), [fileName]); const isMd = language === 'markdown'; const isFlowDiagram = isFlowDiagramFile(fileName); @@ -201,6 +209,7 @@ export function EditorPane({ filePath, fileName, content, loadError }: EditorPan content={content} fileName={fileName} filePath={filePath} + documentVersion={documentVersion} loadError={loadError} /> diff --git a/src/renderer/src/components/FilePanel/FilePanel.test.tsx b/src/renderer/src/components/FilePanel/FilePanel.test.tsx index a170adeb..76db524d 100644 --- a/src/renderer/src/components/FilePanel/FilePanel.test.tsx +++ b/src/renderer/src/components/FilePanel/FilePanel.test.tsx @@ -25,6 +25,10 @@ const DIAGRAM_CONTENT = JSON.stringify({ appState: { viewBackgroundColor: '#f8f9fa' }, files: {}, }); +type SaveSuccess = { + ok: true; + document: { content: string; version: string }; +}; const { excalidrawProps, editCount, loadFromBlob, serializeAsJSON } = vi.hoisted(() => ({ excalidrawProps: { current: null as Record | null }, @@ -106,8 +110,10 @@ vi.mock('@excalidraw/excalidraw', () => ({ const readFile = vi.fn(); const writeFile = vi.fn(); +const fsReadFile = vi.fn(); +const fsWriteFile = vi.fn(); const directoryChangeListeners: Array<( - data: { type: string; path: string }, + data: { filePath: string; version: string | null; mutationId?: string }, ) => void> = []; beforeAll(() => { @@ -137,23 +143,41 @@ beforeEach(() => { serializeAsJSON.mockClear(); readFile.mockReset().mockResolvedValue({ ok: true, - data: { content: DIAGRAM_CONTENT }, + document: { content: DIAGRAM_CONTENT, version: 'version-1' }, }); - writeFile.mockReset().mockResolvedValue({ ok: true, data: undefined }); + let savedVersion = 1; + writeFile.mockReset().mockImplementation(async ( + _rootPath: string, + _filePath: string, + savedContent: string, + ) => ({ + ok: true, + document: { + content: savedContent, + version: `version-${++savedVersion}`, + }, + })); + fsReadFile.mockReset(); + fsWriteFile.mockReset(); directoryChangeListeners.length = 0; (window as unknown as { electronAPI: unknown }).electronAPI = { store: { get: vi.fn().mockResolvedValue(false) }, + flowDiagram: { + loadDocument: readFile, + saveDocument: writeFile, + onDocumentChange: vi.fn((callback) => { + directoryChangeListeners.push(callback); + return vi.fn(); + }), + }, fs: { readDirectory: vi.fn().mockResolvedValue({ ok: true, data: [] }), - readFile, - writeFile, + readFile: fsReadFile, + writeFile: fsWriteFile, watchDirectory: vi.fn().mockResolvedValue({ ok: true, data: undefined }), unwatchDirectory: vi.fn().mockResolvedValue({ ok: true, data: undefined }), - onDirectoryChange: vi.fn((callback) => { - directoryChangeListeners.push(callback); - return vi.fn(); - }), + onDirectoryChange: vi.fn(() => vi.fn()), }, }; @@ -215,6 +239,7 @@ describe('Editable Flow Diagram workspace', () => { await screen.findByTestId('official-excalidraw'); expect(readFile).toHaveBeenCalledTimes(1); + expect(fsReadFile).not.toHaveBeenCalled(); expect(useFileStore.getState().openTabs).toHaveLength(1); expect(useFileStore.getState().previewFile?.path).toBe(DIAGRAM_PATH); await waitFor(() => expect(excalidrawProps.current?.initialData).toMatchObject({ @@ -251,17 +276,20 @@ describe('Editable Flow Diagram workspace', () => { loadFromBlob.mockResolvedValue(agentDiagram); readFile.mockResolvedValue({ ok: true, - data: { content: JSON.stringify({ - type: 'excalidraw', - version: 2, - source: 'https://cdf.local', - ...agentDiagram, - }) }, + document: { + content: JSON.stringify({ + type: 'excalidraw', + version: 2, + source: 'https://cdf.local', + ...agentDiagram, + }), + version: 'version-agent-1', + }, }); await act(async () => { for (const listener of directoryChangeListeners) { - listener({ type: 'change', path: DIAGRAM_PATH }); + listener({ filePath: DIAGRAM_PATH, version: 'version-agent-1' }); } }); @@ -281,6 +309,7 @@ describe('Editable Flow Diagram workspace', () => { path: `${PROJECT_PATH}/diagrams/second.excalidraw`, name: 'second.excalidraw', content: DIAGRAM_CONTENT, + documentVersion: 'version-second' as never, }); useFileStore.getState().setActiveTab(0); }); @@ -295,17 +324,20 @@ describe('Editable Flow Diagram workspace', () => { loadFromBlob.mockResolvedValue(agentDiagram); readFile.mockResolvedValue({ ok: true, - data: { content: JSON.stringify({ - type: 'excalidraw', - version: 2, - source: 'https://cdf.local', - ...agentDiagram, - }) }, + document: { + content: JSON.stringify({ + type: 'excalidraw', + version: 2, + source: 'https://cdf.local', + ...agentDiagram, + }), + version: 'version-agent-1', + }, }); await act(async () => { for (const listener of directoryChangeListeners) { - listener({ type: 'change', path: DIAGRAM_PATH }); + listener({ filePath: DIAGRAM_PATH, version: 'version-agent-1' }); } await Promise.resolve(); }); @@ -377,7 +409,8 @@ describe('Editable Flow Diagram workspace', () => { PROJECT_PATH, DIAGRAM_PATH, expect.any(String), - DIAGRAM_CONTENT, + 'version-1', + expect.stringMatching(/^flow-diagram-/), ); const savedDocument = JSON.parse(writeFile.mock.calls[0][2]); expect(useFileStore.getState().previewFile?.content).toBe(writeFile.mock.calls[0][2]); @@ -401,8 +434,134 @@ describe('Editable Flow Diagram workspace', () => { expect(useFileStore.getState().dirtyTabs[DIAGRAM_PATH]).toBe(false); }); + it('uses a save conflict snapshot directly and restores against its latest version', async () => { + writeFile.mockResolvedValueOnce({ + ok: false, + error: { + code: 'SOURCE_CHANGED', + message: 'changed', + currentContent: DIAGRAM_CONTENT, + currentVersion: 'version-conflict', + }, + }); + render(
); + fireEvent.click(screen.getByRole('button', { name: 'release.excalidraw' })); + await screen.findByTestId('official-excalidraw'); + vi.useFakeTimers(); + + fireEvent.click(screen.getByRole('button', { name: 'Draw in diagram' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(screen.getByRole('button', { + name: /恢复我的编辑|Restore my edits/, + })).toBeTruthy(); + expect(readFile).toHaveBeenCalledTimes(1); + expect(fsReadFile).not.toHaveBeenCalled(); + + fireEvent.click(screen.getByRole('button', { + name: /恢复我的编辑|Restore my edits/, + })); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(writeFile).toHaveBeenCalledTimes(2); + expect(writeFile.mock.calls[1][3]).toBe('version-conflict'); + expect(useFileStore.getState().dirtyTabs[DIAGRAM_PATH]).toBe(false); + }); + + it('ignores the versioned notification produced by its own active save', async () => { + writeFile.mockImplementationOnce(async ( + _rootPath: string, + _filePath: string, + savedContent: string, + _expectedVersion: string, + mutationId: string, + ) => { + for (const listener of directoryChangeListeners) { + listener({ + filePath: DIAGRAM_PATH, + version: 'version-2', + mutationId, + }); + } + return { + ok: true, + document: { content: savedContent, version: 'version-2' }, + }; + }); + render(
); + fireEvent.click(screen.getByRole('button', { name: 'release.excalidraw' })); + await screen.findByTestId('official-excalidraw'); + vi.useFakeTimers(); + + fireEvent.click(screen.getByRole('button', { name: 'Draw in diagram' })); + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + expect(readFile).toHaveBeenCalledTimes(1); + expect(screen.queryByRole('button', { + name: /恢复我的编辑|Restore my edits/, + })).toBeNull(); + expect(useFileStore.getState().dirtyTabs[DIAGRAM_PATH]).toBe(false); + }); + + it('does not regress when versioned external notifications resolve out of order', async () => { + let resolveOlder: ((value: unknown) => void) | undefined; + let resolveLatest: ((value: unknown) => void) | undefined; + render(
); + fireEvent.click(screen.getByRole('button', { name: 'release.excalidraw' })); + await screen.findByTestId('official-excalidraw'); + + readFile + .mockImplementationOnce(() => new Promise((resolve) => { + resolveOlder = resolve; + })) + .mockImplementationOnce(() => new Promise((resolve) => { + resolveLatest = resolve; + })); + const latestContent = DIAGRAM_CONTENT.replace('"elements":[]', '"elements":[{"id":"latest"}]'); + const olderContent = DIAGRAM_CONTENT.replace('"elements":[]', '"elements":[{"id":"older"}]'); + + act(() => { + for (const listener of directoryChangeListeners) { + listener({ filePath: DIAGRAM_PATH, version: 'version-older' }); + listener({ filePath: DIAGRAM_PATH, version: 'version-latest' }); + } + }); + await act(async () => { + resolveLatest?.({ + ok: true, + document: { content: latestContent, version: 'version-latest' }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await act(async () => { + resolveOlder?.({ + ok: true, + document: { content: olderContent, version: 'version-older' }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(useFileStore.getState().previewFile).toMatchObject({ + content: latestContent, + documentVersion: 'version-latest', + }); + }); + expect(useFileStore.getState().previewFile?.content).not.toBe(olderContent); + }); + it('serializes overlapping saves so an older write cannot replace the latest edit', async () => { - let resolveFirstWrite: ((value: { ok: true; data: undefined }) => void) | undefined; + let resolveFirstWrite: ((value: SaveSuccess) => void) | undefined; writeFile.mockImplementationOnce(() => new Promise((resolve) => { resolveFirstWrite = resolve; })); @@ -426,7 +585,10 @@ describe('Editable Flow Diagram workspace', () => { expect(writeFile).toHaveBeenCalledTimes(1); await act(async () => { - resolveFirstWrite?.({ ok: true, data: undefined }); + resolveFirstWrite?.({ + ok: true, + document: { content: writeFile.mock.calls[0][2], version: 'version-2' }, + }); await Promise.resolve(); await Promise.resolve(); }); @@ -473,8 +635,8 @@ describe('Editable Flow Diagram workspace', () => { }); it('waits for edits made while close-time flush is still in progress', async () => { - let resolveFirstWrite: ((value: { ok: true; data: undefined }) => void) | undefined; - let resolveSecondWrite: ((value: { ok: true; data: undefined }) => void) | undefined; + let resolveFirstWrite: ((value: SaveSuccess) => void) | undefined; + let resolveSecondWrite: ((value: SaveSuccess) => void) | undefined; writeFile .mockImplementationOnce(() => new Promise((resolve) => { resolveFirstWrite = resolve; @@ -497,7 +659,10 @@ describe('Editable Flow Diagram workspace', () => { fireEvent.click(drawButton); await act(async () => { - resolveFirstWrite?.({ ok: true, data: undefined }); + resolveFirstWrite?.({ + ok: true, + document: { content: writeFile.mock.calls[0][2], version: 'version-2' }, + }); await Promise.resolve(); await Promise.resolve(); }); @@ -505,7 +670,10 @@ describe('Editable Flow Diagram workspace', () => { expect(useFileStore.getState().openTabs).toHaveLength(1); await act(async () => { - resolveSecondWrite?.({ ok: true, data: undefined }); + resolveSecondWrite?.({ + ok: true, + document: { content: writeFile.mock.calls[1][2], version: 'version-3' }, + }); await Promise.resolve(); await Promise.resolve(); }); @@ -534,7 +702,10 @@ describe('Editable Flow Diagram workspace', () => { it('shows a read-only error without overwriting invalid or unreadable diagrams', async () => { loadFromBlob.mockRejectedValueOnce(new Error('invalid document')); - readFile.mockResolvedValueOnce({ ok: true, data: { content: '{not-json' } }); + readFile.mockResolvedValueOnce({ + ok: true, + document: { content: '{not-json', version: 'version-invalid' }, + }); const { unmount } = render(
); fireEvent.click(screen.getByRole('button', { name: 'release.excalidraw' })); diff --git a/src/renderer/src/components/FilePanel/FilePanel.tsx b/src/renderer/src/components/FilePanel/FilePanel.tsx index 72bf9250..5b35a90f 100644 --- a/src/renderer/src/components/FilePanel/FilePanel.tsx +++ b/src/renderer/src/components/FilePanel/FilePanel.tsx @@ -293,6 +293,7 @@ export function FilePanel() { filePath={previewFile.path} fileName={previewFile.name} content={previewFile.content} + documentVersion={previewFile.documentVersion} loadError={previewFile.loadError} />
state.theme); const rootPath = useFileStore((state) => state.rootPath); @@ -48,17 +70,14 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl const [diagramRevision, setDiagramRevision] = useState(0); const [conflictedContent, setConflictedContent] = useState(null); const conflictedContentRef = useRef(null); - const editGenerationRef = useRef(0); const saveTimerRef = useRef | null>(null); - const lastSavedContentRef = useRef(null); - const lastDiskContentRef = useRef(null); + const baselineContentRef = useRef(null); + const documentVersionRef = useRef(null); const pendingContentRef = useRef(null); - const saveQueueRef = useRef>(Promise.resolve(true)); - const lastQueuedContentRef = useRef(null); - const queuedDiskContentRef = useRef(null); - const externalReloadVersionRef = useRef(0); - const externalReloadPromiseRef = useRef | null>(null); - const externalPendingPreservationRef = useRef(null); + const saveOperationRef = useRef | null>(null); + const savingContentRef = useRef(null); + const activeMutationIdRef = useRef(null); + const latestNotifiedVersionRef = useRef(null); useEffect(() => { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); @@ -67,58 +86,144 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl return () => mediaQuery.removeEventListener('change', handleChange); }, []); - const persist = useCallback((contentToSave: string): Promise => { - if (!rootPath) return Promise.resolve(false); - if (lastQueuedContentRef.current === contentToSave) { - return saveQueueRef.current; + const installAuthoritativeSnapshot = useCallback(async ( + snapshot: FlowDiagramDocumentSnapshot, + preservedLocalContent: string | null, + notificationGuard?: FlowDiagramDocumentVersion | null, + ): Promise => { + try { + const diagram = await restoreFlowDiagram(snapshot.content); + if ( + notificationGuard !== undefined + && latestNotifiedVersionRef.current !== notificationGuard + ) { + return true; + } + const restoredContent = serializeFlowDiagram( + diagram.elements, + diagram.appState, + diagram.files, + ); + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + pendingContentRef.current = null; + baselineContentRef.current = restoredContent; + documentVersionRef.current = snapshot.version; + + const hasConflict = Boolean( + preservedLocalContent && preservedLocalContent !== restoredContent, + ); + const nextConflict = hasConflict ? preservedLocalContent : null; + conflictedContentRef.current = nextConflict; + setConflictedContent(nextConflict); + setTabContent(filePath, snapshot.content, snapshot.version); + setTabDirty(filePath, hasConflict); + setSaveState(hasConflict ? 'dirty' : 'saved'); + setDiagramRevision((current) => current + 1); + setLoadState({ status: 'ready', diagram }); + return !hasConflict; + } catch { + if ( + notificationGuard !== undefined + && latestNotifiedVersionRef.current !== notificationGuard + ) { + return true; + } + if (preservedLocalContent && !conflictedContentRef.current) { + conflictedContentRef.current = preservedLocalContent; + setConflictedContent(preservedLocalContent); + } + setTabDirty(filePath, conflictedContentRef.current !== null); + setSaveState(conflictedContentRef.current ? 'dirty' : 'error'); + setLoadState({ status: 'invalid', reason: 'invalid' }); + return false; } + }, [filePath, setTabContent, setTabDirty]); - const expectedDiskContent = queuedDiskContentRef.current ?? lastDiskContentRef.current ?? undefined; - lastQueuedContentRef.current = contentToSave; - queuedDiskContentRef.current = contentToSave; - const operation = saveQueueRef.current.then(async () => { - setSaveState('saving'); - try { - const result = await window.electronAPI.fs.writeFile( - rootPath, - filePath, - contentToSave, - expectedDiskContent, - ); - if (!result.ok) { - console.error('[FlowDiagramEditor] Save failed:', result.error.message); + const persistPending = useCallback((): Promise => { + if (!rootPath) return Promise.resolve(false); + if (saveOperationRef.current) return saveOperationRef.current; + + const operation = (async () => { + while (pendingContentRef.current && !conflictedContentRef.current) { + const contentToSave = pendingContentRef.current; + const expectedVersion = documentVersionRef.current; + if (!expectedVersion) { setSaveState('error'); return false; } - lastSavedContentRef.current = contentToSave; - lastDiskContentRef.current = contentToSave; - setTabContent(filePath, contentToSave); - if (pendingContentRef.current === contentToSave) { - pendingContentRef.current = null; - const hasConflict = conflictedContentRef.current !== null; - setTabDirty(filePath, hasConflict); - setSaveState(hasConflict ? 'dirty' : 'saved'); - } else { - setSaveState('dirty'); + const mutationId = nextMutationId(); + savingContentRef.current = contentToSave; + activeMutationIdRef.current = mutationId; + setSaveState('saving'); + + try { + const result = await window.electronAPI.flowDiagram.saveDocument( + rootPath, + filePath, + contentToSave, + expectedVersion, + mutationId, + ); + if (!result.ok) { + if (result.error.code === FLOW_DIAGRAM_SOURCE_CHANGED) { + const preserved = pendingContentRef.current ?? contentToSave; + pendingContentRef.current = null; + if (result.error.currentContent != null && result.error.currentVersion) { + await installAuthoritativeSnapshot( + { + content: result.error.currentContent, + version: result.error.currentVersion, + }, + preserved, + ); + } else { + conflictedContentRef.current = preserved; + setConflictedContent(preserved); + setTabDirty(filePath, true); + setSaveState('dirty'); + } + return false; + } + console.error('[FlowDiagramEditor] Save failed:', result.error.message); + setSaveState('error'); + return false; + } + + documentVersionRef.current = result.document.version; + baselineContentRef.current = contentToSave; + setTabContent( + filePath, + result.document.content, + result.document.version, + ); + if (pendingContentRef.current === contentToSave) { + pendingContentRef.current = null; + } + const isDirty = pendingContentRef.current !== null; + setTabDirty(filePath, isDirty); + setSaveState(isDirty ? 'dirty' : 'saved'); + } catch (error) { + console.error('[FlowDiagramEditor] Save error:', error); + setSaveState('error'); + return false; + } finally { + savingContentRef.current = null; + activeMutationIdRef.current = null; } - return true; - } catch (error) { - console.error('[FlowDiagramEditor] Save error:', error); - setSaveState('error'); - return false; } - }); + return conflictedContentRef.current === null; + })(); - saveQueueRef.current = operation; + saveOperationRef.current = operation; void operation.finally(() => { - if (lastQueuedContentRef.current === contentToSave) { - lastQueuedContentRef.current = null; - queuedDiskContentRef.current = null; - } + if (saveOperationRef.current === operation) saveOperationRef.current = null; }); return operation; - }, [filePath, rootPath, setTabContent, setTabDirty]); + }, [filePath, installAuthoritativeSnapshot, rootPath, setTabContent, setTabDirty]); const scheduleSave = useCallback((serialized: string) => { pendingContentRef.current = serialized; @@ -127,28 +232,21 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { saveTimerRef.current = null; - void persist(serialized); + void persistPending(); }, AUTOSAVE_DELAY_MS); - }, [filePath, persist, setTabDirty]); + }, [filePath, persistPending, setTabDirty]); const flushPendingSave = useCallback(async (): Promise => { - while (externalReloadPromiseRef.current) { - const reload = externalReloadPromiseRef.current; - if (!await reload) return false; - if (externalReloadPromiseRef.current === reload) externalReloadPromiseRef.current = null; + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; } if (conflictedContentRef.current) return false; - while (true) { - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - const pending = pendingContentRef.current; - const saved = await (pending ? persist(pending) : saveQueueRef.current); - if (!saved) return false; - if (!pendingContentRef.current) return true; + while (pendingContentRef.current || saveOperationRef.current) { + if (!await persistPending()) return false; } - }, [persist]); + return true; + }, [persistPending]); const handleChange = useCallback(( elements: FlowDiagramElements, @@ -156,29 +254,35 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl files: FlowDiagramFiles, ) => { const serialized = serializeFlowDiagram(elements, appState, files); + if (serialized === pendingContentRef.current) return; if ( - serialized === lastSavedContentRef.current - || serialized === pendingContentRef.current + serialized === baselineContentRef.current + && savingContentRef.current === null ) { + if (saveTimerRef.current) { + clearTimeout(saveTimerRef.current); + saveTimerRef.current = null; + } + pendingContentRef.current = null; + setTabDirty(filePath, false); + setSaveState('saved'); return; } - editGenerationRef.current += 1; scheduleSave(serialized); - }, [scheduleSave]); + }, [filePath, scheduleSave, setTabDirty]); useEffect(() => { let cancelled = false; - if (lastSavedContentRef.current !== null && lastDiskContentRef.current === content) { + if (loadError || !documentVersion) { + setLoadState({ status: 'invalid', reason: 'unreadable' }); return () => { cancelled = true; }; } - lastSavedContentRef.current = null; - lastDiskContentRef.current = content; - pendingContentRef.current = null; - - if (loadError) { - setLoadState({ status: 'invalid', reason: 'unreadable' }); + if ( + baselineContentRef.current !== null + && documentVersionRef.current === documentVersion + ) { return () => { cancelled = true; }; @@ -188,11 +292,14 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl restoreFlowDiagram(content) .then((diagram) => { if (cancelled) return; - lastSavedContentRef.current = serializeFlowDiagram( + baselineContentRef.current = serializeFlowDiagram( diagram.elements, diagram.appState, diagram.files, ); + documentVersionRef.current = documentVersion; + pendingContentRef.current = null; + setTabDirty(filePath, false); setSaveState('saved'); setDiagramRevision((current) => current + 1); setLoadState({ status: 'ready', diagram }); @@ -204,103 +311,64 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl return () => { cancelled = true; }; - }, [content, loadError]); + }, [content, documentVersion, filePath, loadError, setTabDirty]); useEffect(() => { if (!rootPath) return; - const unsubscribe = window.electronAPI.fs.onDirectoryChange((data) => { - if (data.path.replace(/\\/g, '/') !== filePath.replace(/\\/g, '/')) return; - const version = ++externalReloadVersionRef.current; - const previousReload = externalReloadPromiseRef.current ?? Promise.resolve(true); - const reload = previousReload.then(async () => { - if (externalReloadVersionRef.current !== version) return true; - const generationAtNotification = editGenerationRef.current; - const pendingAtNotification = pendingContentRef.current; - if (pendingAtNotification) { - externalPendingPreservationRef.current ??= pendingAtNotification; - } - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - pendingContentRef.current = null; - await saveQueueRef.current; - const result = await window.electronAPI.fs.readFile(rootPath, filePath); - if (externalReloadVersionRef.current !== version) return true; - if (!result.ok || 'binary' in result.data) { - const preserved = pendingContentRef.current ?? externalPendingPreservationRef.current; - externalPendingPreservationRef.current = null; - pendingContentRef.current = null; + const unsubscribe = window.electronAPI.flowDiagram.onDocumentChange((event) => { + if (normalizedPath(event.filePath) !== normalizedPath(filePath)) return; + if (event.mutationId && event.mutationId === activeMutationIdRef.current) return; + if (event.version && event.version === documentVersionRef.current) return; + + latestNotifiedVersionRef.current = event.version; + const notificationVersion = event.version; + void (async () => { + const result = await window.electronAPI.flowDiagram.loadDocument(rootPath, filePath); + if (latestNotifiedVersionRef.current !== notificationVersion) return; + if (!result.ok) { + const preserved = ( + conflictedContentRef.current + ?? pendingContentRef.current + ?? savingContentRef.current + ); if (preserved && !conflictedContentRef.current) { conflictedContentRef.current = preserved; setConflictedContent(preserved); } + pendingContentRef.current = null; setTabDirty(filePath, conflictedContentRef.current !== null); setSaveState(conflictedContentRef.current ? 'dirty' : 'error'); - return false; + setLoadState({ status: 'invalid', reason: 'unreadable' }); + return; } - try { - const diagram = await restoreFlowDiagram(result.data.content); - const restoredContent = serializeFlowDiagram( - diagram.elements, - diagram.appState, - diagram.files, - ); - const pendingAfterWait = pendingContentRef.current; - const editedWhileWaiting = editGenerationRef.current !== generationAtNotification; - if (restoredContent === lastSavedContentRef.current) { - const preserved = pendingAfterWait ?? externalPendingPreservationRef.current; - externalPendingPreservationRef.current = null; - if (preserved && preserved !== restoredContent) scheduleSave(preserved); - return true; - } + if (result.document.version === documentVersionRef.current) return; - const preservedContent = editedWhileWaiting - ? (pendingAfterWait ?? externalPendingPreservationRef.current) - : (pendingAtNotification ?? externalPendingPreservationRef.current); - if (saveTimerRef.current) { - clearTimeout(saveTimerRef.current); - saveTimerRef.current = null; - } - pendingContentRef.current = null; - externalPendingPreservationRef.current = null; - if (preservedContent && preservedContent !== restoredContent && !conflictedContentRef.current) { - conflictedContentRef.current = preservedContent; - setConflictedContent(preservedContent); - } - - setLoadState({ status: 'loading' }); - lastSavedContentRef.current = restoredContent; - lastDiskContentRef.current = result.data.content; - setSaveState(conflictedContentRef.current ? 'dirty' : 'saved'); - setTabDirty(filePath, conflictedContentRef.current !== null); - setTabContent(filePath, result.data.content); - setDiagramRevision((current) => current + 1); - setLoadState({ status: 'ready', diagram }); - return conflictedContentRef.current === null; - } catch { - const preserved = pendingContentRef.current ?? externalPendingPreservationRef.current; - externalPendingPreservationRef.current = null; - pendingContentRef.current = null; - if (preserved && !conflictedContentRef.current) { - conflictedContentRef.current = preserved; - setConflictedContent(preserved); - } - setTabDirty(filePath, conflictedContentRef.current !== null); - setLoadState({ status: 'invalid', reason: 'invalid' }); - return false; + const savingContent = savingContentRef.current; + if (savingContent && result.document.content === savingContent) { + documentVersionRef.current = result.document.version; + baselineContentRef.current = savingContent; + setTabContent( + filePath, + result.document.content, + result.document.version, + ); + return; } - }); - externalReloadPromiseRef.current = reload; - void reload.finally(() => { - if (externalReloadPromiseRef.current === reload) externalReloadPromiseRef.current = null; - }); + + const preserved = ( + conflictedContentRef.current + ?? pendingContentRef.current + ?? savingContent + ); + await installAuthoritativeSnapshot( + result.document, + preserved, + notificationVersion, + ); + })(); }); - return () => { - externalReloadVersionRef.current += 1; - unsubscribe(); - }; - }, [filePath, rootPath, scheduleSave, setTabContent, setTabDirty]); + return unsubscribe; + }, [filePath, installAuthoritativeSnapshot, rootPath, setTabContent, setTabDirty]); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { @@ -332,7 +400,6 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl setConflictedContent(null); setDiagramRevision((current) => current + 1); setLoadState({ status: 'ready', diagram }); - editGenerationRef.current += 1; scheduleSave(conflictedContent); } catch { setLoadState({ status: 'invalid', reason: 'invalid' }); @@ -350,26 +417,18 @@ export function FlowDiagramEditor({ content, fileName, filePath, loadError }: Fl if (!rootPath) return; setLoadState({ status: 'loading' }); const result = await reloadProjectFile(rootPath, filePath, fileName); - if (!result.ok || result.file.loadError) { + if (!result.ok || result.file.loadError || !result.file.documentVersion) { setLoadState({ status: 'invalid', reason: 'unreadable' }); return; } - - try { - const diagram = await restoreFlowDiagram(result.file.content); - lastSavedContentRef.current = serializeFlowDiagram( - diagram.elements, - diagram.appState, - diagram.files, - ); - lastDiskContentRef.current = result.file.content; - setSaveState('saved'); - setDiagramRevision((current) => current + 1); - setLoadState({ status: 'ready', diagram }); - } catch { - setLoadState({ status: 'invalid', reason: 'invalid' }); - } - }, [fileName, filePath, rootPath]); + await installAuthoritativeSnapshot( + { + content: result.file.content, + version: result.file.documentVersion, + }, + null, + ); + }, [fileName, filePath, installAuthoritativeSnapshot, rootPath]); if (loadState.status === 'loading') { return ( diff --git a/src/renderer/src/lib/openProjectFile.ts b/src/renderer/src/lib/openProjectFile.ts index 5bdd5b1d..b27ea893 100644 --- a/src/renderer/src/lib/openProjectFile.ts +++ b/src/renderer/src/lib/openProjectFile.ts @@ -60,16 +60,25 @@ async function openProjectFileFromDisk( const isFlowDiagram = isFlowDiagramFile(fileName); try { + if (isFlowDiagram) { + const result = await window.electronAPI.flowDiagram.loadDocument(rootPath, filePath); + if (!result.ok) return openUnreadableDiagram(); + const file = { + path: filePath, + name: fileName, + content: result.document.content, + documentVersion: result.document.version, + }; + useFileStore.getState().openPreview(file); + return { ok: true, file, reused: false }; + } + const result = await window.electronAPI.fs.readFile(rootPath, filePath); if (!result.ok) { - return isFlowDiagram - ? openUnreadableDiagram() - : { ok: false, message: result.error.message }; + return { ok: false, message: result.error.message }; } if ('binary' in result.data) { - return isFlowDiagram - ? openUnreadableDiagram() - : { ok: false, message: 'Binary files cannot be opened in the editor.' }; + return { ok: false, message: 'Binary files cannot be opened in the editor.' }; } const file = { diff --git a/src/renderer/src/stores/fileStore.ts b/src/renderer/src/stores/fileStore.ts index bca95de2..d0079a0d 100644 --- a/src/renderer/src/stores/fileStore.ts +++ b/src/renderer/src/stores/fileStore.ts @@ -1,10 +1,12 @@ import { create } from 'zustand'; import type { DirectoryEntry } from '@shared/types'; +import type { FlowDiagramDocumentVersion } from '@shared/flow-diagrams'; export interface PreviewFile { path: string; name: string; content: string; + documentVersion?: FlowDiagramDocumentVersion; loadError?: 'unreadable'; } @@ -25,7 +27,11 @@ interface FileState { fileTreeCollapsed: boolean; dirtyTabs: Record; setTabDirty: (path: string, dirty: boolean) => void; - setTabContent: (path: string, content: string) => void; + setTabContent: ( + path: string, + content: string, + documentVersion?: FlowDiagramDocumentVersion, + ) => void; setFilePanelOpen: (open: boolean) => void; toggleFilePanel: () => void; @@ -71,12 +77,14 @@ export const useFileStore = create((set) => ({ setFilePanelWidth: (width) => set({ filePanelWidth: width }), setRootPath: (path) => set({ rootPath: path, expandedDirs: {}, dirContents: {}, dirErrors: {}, filterQuery: '', openTabs: [], activeTabIndex: -1, previewFile: null, filePanelMode: 'tree', filePanelWidth: 280, selectedPath: null, fileTreeCollapsed: false, dirtyTabs: {} }), setTabDirty: (path, dirty) => set((s) => ({ dirtyTabs: { ...s.dirtyTabs, [path]: dirty } })), - setTabContent: (path, content) => set((s) => { + setTabContent: (path, content, documentVersion) => set((s) => { const openTabs = s.openTabs.map((tab) => ( - tab.path === path ? { ...tab, content, loadError: undefined } : tab + tab.path === path + ? { ...tab, content, documentVersion, loadError: undefined } + : tab )); const previewFile = s.previewFile?.path === path - ? { ...s.previewFile, content, loadError: undefined } + ? { ...s.previewFile, content, documentVersion, loadError: undefined } : s.previewFile; return { openTabs, previewFile }; }), diff --git a/src/shared/flow-diagrams.ts b/src/shared/flow-diagrams.ts index 4f7ea97f..709b8e9c 100644 --- a/src/shared/flow-diagrams.ts +++ b/src/shared/flow-diagrams.ts @@ -21,3 +21,47 @@ export type FlowDiagramExportResponse = export const FLOW_DIAGRAM_EXPORT_REQUEST_CHANNEL = 'flow-diagram:export-request'; export const FLOW_DIAGRAM_EXPORT_RESPONSE_CHANNEL = 'flow-diagram:export-response'; + +declare const flowDiagramDocumentVersionBrand: unique symbol; + +/** + * 精确文档字节的 opaque identity。调用方只能比较相等性,不能推断顺序。 + */ +export type FlowDiagramDocumentVersion = string & { + readonly [flowDiagramDocumentVersionBrand]: 'FlowDiagramDocumentVersion'; +}; + +export interface FlowDiagramDocumentSnapshot { + content: string; + version: FlowDiagramDocumentVersion; +} + +export interface FlowDiagramDocumentChangeEvent { + filePath: string; + version: FlowDiagramDocumentVersion | null; + /** Present only for the CDF renderer mutation that directly caused this publication. */ + mutationId?: string; +} + +export type FlowDiagramDocumentReadResult = + | { ok: true; document: FlowDiagramDocumentSnapshot } + | { ok: false; error: { code: string; message: string } }; + +/** 文档在保存基线之后被外部改写时的冲突码(编辑器与文档存储共享)。 */ +export const FLOW_DIAGRAM_SOURCE_CHANGED = 'SOURCE_CHANGED' as const; + +/** + * Flow Diagram 文档存储的保存结果。成功与 SOURCE_CHANGED 都附带权威版本, + * 编辑器无需通过通用文件系统 API 二次读取或自行维护磁盘内容镜像。 + */ +export type FlowDiagramDocumentSaveResult = + | { ok: true; document: FlowDiagramDocumentSnapshot } + | { + ok: false; + error: { + code: string; + message: string; + currentContent?: string | null; + currentVersion?: FlowDiagramDocumentVersion | null; + }; + }; diff --git a/src/shared/ipc-contract.ts b/src/shared/ipc-contract.ts index 3782bf0f..a2a43db6 100644 --- a/src/shared/ipc-contract.ts +++ b/src/shared/ipc-contract.ts @@ -32,6 +32,12 @@ import type { } from './conversations'; import type { ContextAggregate } from './context'; import type { BinaryFileInfo, DirectoryEntry, FileContent, FileError, FileInfo } from './filesystem'; +import type { + FlowDiagramDocumentChangeEvent, + FlowDiagramDocumentReadResult, + FlowDiagramDocumentSaveResult, + FlowDiagramDocumentVersion, +} from './flow-diagrams'; import type { KnowledgeEntryCreateInput, KnowledgeEntrySearchOptions, @@ -257,9 +263,24 @@ export interface IpcInvokeContract { }; 'fs:getFileInfo': { args: [rootPath: string, filePath: string]; result: FsData }; 'fs:writeFile': { - args: [rootPath: string, filePath: string, content: string, expectedContent?: string]; + args: [rootPath: string, filePath: string, content: string]; result: FsAck; }; + // Flow Diagram 一致性路径只暴露版本化文档 API,不经通用 fs read/write。 + 'flow-diagram:load-document': { + args: [rootPath: string, filePath: string]; + result: FlowDiagramDocumentReadResult; + }; + 'flow-diagram:save-document': { + args: [ + rootPath: string, + filePath: string, + content: string, + expectedVersion: FlowDiagramDocumentVersion, + mutationId?: string, + ]; + result: FlowDiagramDocumentSaveResult; + }; 'fs:createFile': { args: [rootPath: string, filePath: string]; result: FsAck }; 'fs:createDirectory': { args: [rootPath: string, dirPath: string]; result: FsAck }; 'fs:renameEntry': { args: [rootPath: string, oldPath: string, newName: string]; result: FsAck }; @@ -442,6 +463,8 @@ export const IPC_INVOKE_CHANNELS = [ 'fs:readFile', 'fs:getFileInfo', 'fs:writeFile', + 'flow-diagram:load-document', + 'flow-diagram:save-document', 'fs:createFile', 'fs:createDirectory', 'fs:renameEntry', @@ -483,6 +506,7 @@ export interface IpcEventContract { 'conversation:messages-changed': { sessionId: string }; 'conversation:run-event': ConversationRunStreamEnvelope; 'fs:directoryChange': { type: string; path: string }; + 'flow-diagram:document-change': FlowDiagramDocumentChangeEvent; 'commands:changed': { source: string }; 'commands:fallback': { scope: 'system' | 'project'; dir: string; error: string }; 'capability-jobs:changed': CapabilityJobEvent;