From 9520872d2f5cee7ff3239f3fe1610c6d92e62f4c Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Sun, 23 Aug 2026 18:17:30 +0800 Subject: [PATCH] feat(coding): add PI-105 product file host API --- .../20260823-pi-host-cutover-c4e8a1d7.md | 116 +++++++ electron/api/coding-product-services.ts | 171 ++++++++++ electron/api/context.ts | 2 + electron/api/route-handlers.ts | 2 + electron/api/routes/coding-files.ts | 138 ++++++++ .../conversation-change-tracker.ts | 28 +- electron/coding-projects/project-files.ts | 318 ++++++++++++++++++ electron/coding-projects/skill-registry.ts | 40 +-- electron/coding-runtime/pi/product-tools.ts | 18 +- electron/main/index.ts | 28 ++ shared/coding-product-tools.ts | 100 ++++++ src/lib/coding-product-tools.ts | 56 +++ .../coding-files-host.test.ts | 41 +++ tests/unit/coding-files-routes.test.ts | 102 ++++++ tests/unit/coding-product-services.test.ts | 142 ++++++++ .../unit/coding-product-tools-facade.test.ts | 44 +++ tests/unit/coding-project-files.test.ts | 111 ++++++ 17 files changed, 1419 insertions(+), 38 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260823-pi-host-cutover-c4e8a1d7.md create mode 100644 electron/api/coding-product-services.ts create mode 100644 electron/api/routes/coding-files.ts create mode 100644 electron/coding-projects/project-files.ts create mode 100644 shared/coding-product-tools.ts create mode 100644 src/lib/coding-product-tools.ts create mode 100644 tests/electron-runtime/coding-files-host.test.ts create mode 100644 tests/unit/coding-files-routes.test.ts create mode 100644 tests/unit/coding-product-services.test.ts create mode 100644 tests/unit/coding-product-tools-facade.test.ts create mode 100644 tests/unit/coding-project-files.test.ts diff --git a/.project-docs/30-worklog/tasks/20260823-pi-host-cutover-c4e8a1d7.md b/.project-docs/30-worklog/tasks/20260823-pi-host-cutover-c4e8a1d7.md new file mode 100644 index 0000000..c71b83d --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260823-pi-host-cutover-c4e8a1d7.md @@ -0,0 +1,116 @@ +# Task: Implement PI-105 Pi Host API cutover + +## Identity + +- Task ID: 20260823-pi-host-cutover-c4e8a1d7 +- Mode: Feature +- Branch: codex/20260823-pi-host-cutover-c4e8a1d7-pi-host-cutover +- Worktree: D:\Datas\OthersProjects\makelore-pi-host-cutover-c4e8a1d7 +- Base commit: 7680bfb0cf206142a164b740d56f8be5ea59362a +- Owner: codex +- Status: In Progress + +## Scope + +- Implement `PI-105 — Files, changes, skills, and command Host API` from the + `【架构】opencode2pi` Spec/ticket graph on cumulative PI-090 baseline + `7680bfb0cf206142a164b740d56f8be5ea59362a`. +- Add a Main-owned vendor-neutral Project File Service for active-project + status, find, bounded content and bounded text search. +- Add typed `/api/coding` routes for files, search, Conversation changes, + managed Skills and the Conversation command catalog, plus the Renderer + facade contract half owned by this ticket. +- Compose those routes with the same production `PiProductTools`, attachment + store and `ConversationChangeTracker` instances used by the Pi extension. + +## Intent And Constraints + +- All file inputs are active-project-relative. Reject absolute, traversal and + project-external paths; never project the absolute project root or expose an + arbitrary filesystem proxy. +- Preserve the currently supported file/status/find/content/search UX while + bounding file sizes, result counts and search output. Do not depend on the + OpenCode runtime or its wire DTOs. +- Conversation changes come only from the shared PI-090 tracker and are + associated with the requested Conversation/run. Do not query Pi session + diff, parse message text, or create a second tracker/state source. +- Skills come only from packaged managed resources and the target Agent's + explicit selection. Commands merge Makelore, valid Pi and selected-Skill + entries with stable DTOs and Makelore-first deduplication. +- The new `/api/coding` route/type/test surface contains no todo, share, + revert/unrevert, checkpoint or file-rollback shape. Existing + `/api/opencode` code remains untouched for PI-140 removal. +- Exclude PI-100 project/Agent/Conversation CRUD, prompt, SSE, interactions and + runtime diagnostics; exclude PI-110/120/130 Renderer state/UI work. +- Do not invent an attachment read endpoint: neither PI-105 nor the normative + route table assigns its transport. +- Real external Provider verification remains **Explicitly Waived / Accepted + Risk** with `realTurnVerified=false`; macOS x64/arm64 remains deferred to + mandatory PI-150. Neither is a Pass. +- Do not create subagents. Keep all writes in this isolated worktree and leave + canonical project memory to a later integration task. + +## Plan + +1. Define the smallest vendor-neutral file/change/skill/command contracts and + lock the supported file UX and path boundaries with focused tests. +2. Implement the Main-owned Project File Service and the PI-105 leaf routes, + including the minimal planner-approved + `GET /api/coding/conversations/:id/changes` route. +3. Wire one shared product-services composition into `HostApiContext`, route + registration and Electron Main without taking PI-100 route ownership. +4. Add focused service/route/facade/dispatcher/Electron seam coverage for path + rejection, redaction, bounds, tracker identity and managed catalog rules. +5. Run focused tests, typecheck, lint, full unit, Vite build, Windows Electron + verification and project-document gates; commit and request planner review. + +## Outcome + +- Added shared vendor-neutral file/change/Skill/command DTOs and a typed + Renderer facade for the seven PI-105 endpoints. +- Added a Main-owned `CodingProjectFileService` with Git-aware discovery, + bounded non-Git fallback, bounded UTF-8 previews/search, binary rejection, + ignore-directory handling, and lexical/realpath containment. +- Added the five normative file/search/Skill routes plus Conversation command + and planner-approved changes routes. Known input errors are typed and + unknown filesystem/service failures are redacted. +- Added `CodingProductHost` composition and wired Electron Main to one shared + attachment store, `PiProductTools`, and its single + `ConversationChangeTracker`. The current active-project catalog is injected + as a temporary project-selection adapter until PI-100 replaces composition; + no OpenCode runtime call or fallback is used by the new service. +- Product command projection is Makelore-first, accepts only safe Pi metadata, + includes only selected managed Skills, and strips raw Pi/source/provider + fields. The host has a `get_commands` result injection seam for PI-100's + runtime composition. +- Added focused unit and Windows Electron coverage for Git/non-Git files, + Unicode truncation, path rejection/redaction, shared tracker identity, + catalog filtering, seven route DTOs, Renderer encoding, and Main dispatcher. + +## Verification + +- `corepack pnpm exec vitest run tests/unit/coding-project-files.test.ts tests/unit/coding-product-services.test.ts tests/unit/coding-files-routes.test.ts tests/unit/coding-product-tools-facade.test.ts` — Pass, 4 files / 10 tests. +- `corepack pnpm exec vitest run --config vitest.electron.config.ts tests/electron-runtime/coding-files-host.test.ts` — Pass, 1 file / 1 test (focused seam). +- `corepack pnpm run typecheck` — Pass. +- `corepack pnpm run lint:check` — Pass with 6 pre-existing warnings and no errors. +- `corepack pnpm run build:vite` — Pass; existing dynamic-import/chunk-size warnings remain. +- `corepack pnpm test` — Pass on clean rerun, 207 files / 2237 passed / 2 skipped. The first run had one Windows temporary-file `EPERM` and one async AI-hardware loading timeout; both passed when isolated and the complete suite then passed. +- `corepack pnpm run test:electron:windows` — Pass, 3 files / 7 tests. A direct Vitest invocation was intentionally discarded because it bypassed the repository's local-Electron wrapper. +- Real Provider verification — **Explicitly Waived / Accepted Risk**; + `realTurnVerified=false`, not Pass. +- macOS x64/arm64 — deferred to mandatory PI-150, not Pass. + +## Follow-ups + +- PI-100 must pass the target Conversation worker's read-only Pi + `get_commands` projection into `createCodingProductHost.listPiCommands` when + it composes the production `CodingConversationRuntime`. Until that runtime + exists in Main, the route deterministically returns Makelore plus selected + managed-Skill commands. +- PI-100 should replace the injected current active-project catalog adapter + with its vendor-neutral project service without changing the PI-105 host + contract or creating another product-tools/change-tracker instance. + +## Promotion Candidates + +- None recorded. diff --git a/electron/api/coding-product-services.ts b/electron/api/coding-product-services.ts new file mode 100644 index 0000000..6f779a5 --- /dev/null +++ b/electron/api/coding-product-services.ts @@ -0,0 +1,171 @@ +import type { + CodingProjectFileContent, + CodingProjectFileEntry, + CodingTextSearchResult, + ConversationChangesSnapshot, + ProductCodingCommand, + ProductCodingSkill, + ProductPiCommandInput, +} from '../../shared/coding-product-tools'; +import { createCodingConversationStore } from '../coding-projects/conversation-store'; +import type { CodingAttachmentStore } from '../coding-projects/attachment-store'; +import { readCodingProjectConfigV2 } from '../coding-projects/project-config'; +import { CodingProjectFileService } from '../coding-projects/project-files'; +import type { PiProductTools } from '../coding-runtime/pi/product-tools'; + +export interface ActiveCodingProject { + id: string; + path: string; +} + +export interface CodingProductHost { + fileStatus(): Promise; + findFiles(query: string, limit?: number): Promise; + fileContent(path: string): Promise; + searchText(pattern: string): Promise; + listSkills(agentId?: string): Promise; + listCommands(conversationId: string): Promise; + getChanges(conversationId: string): Promise; +} + +export interface CodingProductComposition { + attachments: CodingAttachmentStore; + productTools: PiProductTools; + host: CodingProductHost; +} + +export class CodingProductHostError extends Error { + constructor( + readonly status: 404 | 409, + readonly code: string, + message: string, + ) { + super(message); + } +} + +export interface CodingProductHostOptions { + getActiveProject(): Promise; + productTools: PiProductTools; + files?: CodingProjectFileService; + listPiCommands?(conversationId: string): Promise; +} + +function normalizePiCommands(value: unknown): ProductPiCommandInput[] { + const record = value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; + const candidates = Array.isArray(value) + ? value + : Array.isArray(record?.commands) + ? record.commands + : Array.isArray(record?.data) + ? record.data + : []; + return candidates.flatMap((candidate) => { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) return []; + const command = candidate as Record; + if (typeof command.name !== 'string') return []; + return [{ + name: command.name, + ...(typeof command.description === 'string' ? { description: command.description } : {}), + }]; + }); +} + +export function createCodingProductHost(options: CodingProductHostOptions): CodingProductHost { + const files = options.files ?? new CodingProjectFileService(); + + async function activeProject(): Promise { + const project = await options.getActiveProject(); + if (!project) { + throw new CodingProductHostError( + 409, + 'CODING_ACTIVE_PROJECT_REQUIRED', + 'No active coding project is selected', + ); + } + return project; + } + + async function selectedSkillIds( + projectPath: string, + agentId?: string, + ): Promise { + if (!agentId) return []; + const config = await readCodingProjectConfigV2(projectPath); + if (config.status !== 'valid') { + throw new CodingProductHostError( + 409, + 'CODING_PROJECT_CONFIG_INVALID', + 'Coding project configuration is unavailable', + ); + } + const agent = config.config.agents.find((candidate) => ( + candidate.id === agentId && candidate.enabled && !candidate.archivedAt + )); + if (!agent) { + throw new CodingProductHostError( + 404, + 'CODING_AGENT_NOT_FOUND', + 'Coding project Agent does not exist', + ); + } + return agent.skillIds; + } + + async function conversationContext(conversationId: string): Promise<{ + project: ActiveCodingProject; + skillIds: readonly string[]; + }> { + const project = await activeProject(); + const conversation = await createCodingConversationStore(project.path).get(conversationId); + if (!conversation) { + throw new CodingProductHostError( + 404, + 'CODING_CONVERSATION_NOT_FOUND', + 'Coding Conversation does not exist', + ); + } + return { + project, + skillIds: await selectedSkillIds(project.path, conversation.agentId), + }; + } + + return { + async fileStatus() { + const project = await activeProject(); + return await files.status(project.path); + }, + async findFiles(query, limit) { + const project = await activeProject(); + return await files.find(project.path, query, limit); + }, + async fileContent(filePath) { + const project = await activeProject(); + return await files.content(project.path, filePath); + }, + async searchText(pattern) { + const project = await activeProject(); + return await files.search(project.path, pattern); + }, + async listSkills(agentId) { + const project = await activeProject(); + return await options.productTools.listSkills( + await selectedSkillIds(project.path, agentId), + ); + }, + async listCommands(conversationId) { + const context = await conversationContext(conversationId); + const piCommands = options.listPiCommands + ? normalizePiCommands(await options.listPiCommands(conversationId)) + : []; + return await options.productTools.listCommands(context.skillIds, piCommands); + }, + async getChanges(conversationId) { + await conversationContext(conversationId); + return options.productTools.getChanges(conversationId); + }, + }; +} diff --git a/electron/api/context.ts b/electron/api/context.ts index d474850..207bbb4 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -14,6 +14,7 @@ import type { import type { StaticArtifactSnapshot } from '../services/static-release-server'; import type { BackgroundLifecycleController } from '../main/background-lifecycle'; import type { ReleaseJobManager } from '../services/release-job'; +import type { CodingProductComposition } from './coding-product-services'; export type WorksSubmissionBindingStore = ReturnType; @@ -77,4 +78,5 @@ export interface HostApiContext { imageWorkspace?: DesignWorkspaceModule; lifecycle?: BackgroundLifecycleController; releaseJobs?: ReleaseJobManager; + codingProducts?: CodingProductComposition; } diff --git a/electron/api/route-handlers.ts b/electron/api/route-handlers.ts index 7f67fa7..b2f66d9 100644 --- a/electron/api/route-handlers.ts +++ b/electron/api/route-handlers.ts @@ -17,6 +17,7 @@ import { handleUsageRoutes } from './routes/usage'; import { handleFileRoutes } from './routes/files'; import { handleMeowaGameAssetsRoutes } from './routes/meowa-game-assets'; import { handleAgentBrowserRoutes } from './routes/agent-browser'; +import { handleCodingFileRoutes } from './routes/coding-files'; export type HostApiRouteHandler = ( req: IncomingMessage, @@ -42,6 +43,7 @@ export const hostApiRouteHandlers: readonly HostApiRouteHandler[] = [ handleWorksRoutes, handleAgentBrowserRoutes, handleUserSyncRoutes, + handleCodingFileRoutes, handleOpencodeRoutes, handleSettingsRoutes, handleProviderRoutes, diff --git a/electron/api/routes/coding-files.ts b/electron/api/routes/coding-files.ts new file mode 100644 index 0000000..a0ee512 --- /dev/null +++ b/electron/api/routes/coding-files.ts @@ -0,0 +1,138 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import type { HostApiContext } from '../context'; +import { CodingProductHostError } from '../coding-product-services'; +import { sendJson } from '../route-utils'; + +function unavailable(res: ServerResponse): void { + sendJson(res, 503, { + success: false, + code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE', + error: 'Coding product tools are unavailable', + }); +} + +function serviceError(res: ServerResponse, error: unknown): void { + if (error instanceof CodingProductHostError) { + sendJson(res, error.status, { + success: false, + code: error.code, + error: error.message, + }); + return; + } + const code = error && typeof error === 'object' && 'code' in error + ? String(error.code) + : ''; + if (code === 'ENOENT') { + sendJson(res, 404, { + success: false, + code: 'CODING_FILE_NOT_FOUND', + error: 'Project file does not exist', + }); + return; + } + const message = error instanceof Error ? error.message : ''; + const knownInputError = new Set([ + 'File query is required', + 'File query is too long', + 'Search pattern is required', + 'Search pattern is too long', + 'Project file path must be relative', + 'Project file path escapes the active project', + 'Project file path is not a file', + 'Binary project files cannot be previewed', + 'Project file is not valid UTF-8 text', + ]); + if (knownInputError.has(message)) { + sendJson(res, 400, { + success: false, + code: 'CODING_FILE_REQUEST_INVALID', + error: message, + }); + return; + } + sendJson(res, 500, { + success: false, + code: 'CODING_PRODUCT_TOOL_FAILED', + error: 'Coding product request failed', + }); +} + +export async function handleCodingFileRoutes( + req: IncomingMessage, + res: ServerResponse, + url: URL, + ctx: HostApiContext, +): Promise { + const host = ctx.codingProducts?.host; + const fixedGetRoutes = new Set([ + '/api/coding/files/status', + '/api/coding/files/find', + '/api/coding/files/content', + '/api/coding/search', + '/api/coding/skills', + ]); + const commandMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/commands$/); + const changesMatch = url.pathname.match(/^\/api\/coding\/conversations\/([^/]+)\/changes$/); + if (!fixedGetRoutes.has(url.pathname) && !commandMatch && !changesMatch) return false; + if (req.method !== 'GET') return false; + if (!host) { + unavailable(res); + return true; + } + + try { + if (url.pathname === '/api/coding/files/status') { + sendJson(res, 200, { files: await host.fileStatus() }); + return true; + } + if (url.pathname === '/api/coding/files/find') { + const query = url.searchParams.get('query')?.trim() ?? ''; + const rawLimit = url.searchParams.get('limit'); + const parsedLimit = rawLimit ? Number(rawLimit) : undefined; + sendJson(res, 200, { + files: await host.findFiles( + query, + parsedLimit !== undefined && Number.isSafeInteger(parsedLimit) && parsedLimit > 0 + ? parsedLimit + : undefined, + ), + }); + return true; + } + if (url.pathname === '/api/coding/files/content') { + sendJson(res, 200, { + file: await host.fileContent(url.searchParams.get('path')?.trim() ?? ''), + }); + return true; + } + if (url.pathname === '/api/coding/search') { + sendJson(res, 200, { + matches: await host.searchText(url.searchParams.get('pattern')?.trim() ?? ''), + }); + return true; + } + if (url.pathname === '/api/coding/skills') { + sendJson(res, 200, { + skills: await host.listSkills(url.searchParams.get('agentId')?.trim() || undefined), + }); + return true; + } + if (commandMatch) { + sendJson(res, 200, { + commands: await host.listCommands(decodeURIComponent(commandMatch[1])), + }); + return true; + } + if (changesMatch) { + sendJson(res, 200, { + changes: await host.getChanges(decodeURIComponent(changesMatch[1])), + }); + return true; + } + } catch (error) { + serviceError(res, error); + return true; + } + return false; +} diff --git a/electron/coding-projects/conversation-change-tracker.ts b/electron/coding-projects/conversation-change-tracker.ts index 65711d1..1c8e88c 100644 --- a/electron/coding-projects/conversation-change-tracker.ts +++ b/electron/coding-projects/conversation-change-tracker.ts @@ -1,29 +1,23 @@ import { spawn } from 'node:child_process'; import { open, stat } from 'node:fs/promises'; import path from 'node:path'; +import type { + ConversationChangedFile, + ConversationChangesSnapshot, + CodingProjectFileStatus, +} from '../../shared/coding-product-tools'; + +export type { + ConversationChangedFile, + ConversationChangesSnapshot, +} from '../../shared/coding-product-tools'; const MAX_CHANGED_FILES = 200; const MAX_DIFF_BYTES = 64 * 1024; const MAX_UNTRACKED_PREVIEW_BYTES = 8 * 1024; const MAX_GIT_OUTPUT_BYTES = 8 * 1024 * 1024; -export type ConversationChangedFileStatus = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'; - -export interface ConversationChangedFile { - path: string; - status: ConversationChangedFileStatus; - diff?: string; - preview?: string; - truncated?: boolean; -} - -export interface ConversationChangesSnapshot { - conversationId: string; - runId: string; - git: boolean; - baselineHead: string | null; - files: ConversationChangedFile[]; -} +export type ConversationChangedFileStatus = CodingProjectFileStatus; export interface GitCommandResult { code: number; diff --git a/electron/coding-projects/project-files.ts b/electron/coding-projects/project-files.ts new file mode 100644 index 0000000..c372246 --- /dev/null +++ b/electron/coding-projects/project-files.ts @@ -0,0 +1,318 @@ +import { open, lstat, readdir, realpath } from 'node:fs/promises'; +import path from 'node:path'; +import type { + CodingProjectFileContent, + CodingProjectFileEntry, + CodingProjectFileStatus, + CodingTextSearchResult, +} from '../../shared/coding-product-tools'; +import { + ProcessConversationGitAdapter, + type ConversationGitAdapter, + type GitCommandResult, +} from './conversation-change-tracker'; + +const MAX_STATUS_RESULTS = 200; +const DEFAULT_FIND_RESULTS = 20; +const MAX_FIND_RESULTS = 200; +const MAX_DISCOVERED_FILES = 20_000; +const MAX_CONTENT_BYTES = 256 * 1024; +const MAX_SEARCH_FILE_BYTES = 1024 * 1024; +const MAX_SEARCH_RESULTS = 200; +const MAX_SEARCH_LINE_CHARS = 2_048; +const SKIPPED_DIRECTORIES = new Set([ + '.git', + '.next', + '.nuxt', + '.svelte-kit', + 'build', + 'coverage', + 'dist', + 'node_modules', + 'out', +]); + +function normalizeRelativePath(value: string): string { + const raw = value.trim(); + const slashPath = raw.replaceAll('\\', '/'); + if (!slashPath || slashPath.includes('\0') || path.isAbsolute(raw) + || path.win32.isAbsolute(raw) || path.posix.isAbsolute(slashPath)) { + throw new Error('Project file path must be relative'); + } + const normalized = path.posix.normalize(slashPath).replace(/^\.\//, ''); + if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) { + throw new Error('Project file path escapes the active project'); + } + return normalized; +} + +function projectTarget(projectPath: string, relativePath: string): string { + const root = path.resolve(projectPath); + const target = path.resolve(root, ...relativePath.split('/')); + const relative = path.relative(root, target); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Project file path escapes the active project'); + } + return target; +} + +async function containedExistingTarget(projectPath: string, relativePath: string): Promise { + const root = await realpath(path.resolve(projectPath)); + const target = await realpath(projectTarget(root, relativePath)); + const relative = path.relative(root, target); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error('Project file path escapes the active project'); + } + return target; +} + +function statusFromSignature(signature: string, renamed: boolean): CodingProjectFileStatus { + if (signature === '??') return 'untracked'; + if (renamed || signature.includes('R')) return 'renamed'; + if (signature.includes('D')) return 'deleted'; + if (signature.includes('A')) return 'added'; + return 'modified'; +} + +function parseStatus(value: string): CodingProjectFileEntry[] { + const records = value.split('\0'); + const files: CodingProjectFileEntry[] = []; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (!record || record.startsWith('! ')) continue; + let filePath: string | undefined; + let status: CodingProjectFileStatus; + if (record.startsWith('? ')) { + filePath = record.slice(2); + status = 'untracked'; + } else { + const renamed = record.startsWith('2 '); + const match = renamed + ? record.match(/^2 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s) + : record.match(/^1 ([^ ]+) [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ [^ ]+ (.*)$/s); + if (!match) continue; + filePath = match[2]; + status = statusFromSignature(match[1], renamed); + if (renamed) index += 1; + } + const normalized = normalizeRelativePath(filePath); + files.push({ + path: normalized, + name: path.posix.basename(normalized), + type: 'file', + status, + }); + if (files.length >= MAX_STATUS_RESULTS) break; + } + return files; +} + +async function readBoundedFile( + projectPath: string, + relativePath: string, + maxBytes: number, +): Promise<{ data: Buffer; truncated: boolean }> { + const target = await containedExistingTarget(projectPath, relativePath); + const metadata = await lstat(target); + if (!metadata.isFile()) throw new Error('Project file path is not a file'); + const handle = await open(target, 'r'); + const data = Buffer.alloc(Math.min(metadata.size, maxBytes + 1)); + let bytesRead: number; + try { + ({ bytesRead } = await handle.read(data, 0, data.byteLength, 0)); + } finally { + await handle.close(); + } + return { + data: data.subarray(0, Math.min(bytesRead, maxBytes)), + truncated: metadata.size > maxBytes || bytesRead > maxBytes, + }; +} + +function decodeText(data: Buffer, allowIncompleteSuffix = false): string { + if (data.includes(0)) throw new Error('Binary project files cannot be previewed'); + try { + return new TextDecoder('utf-8', { fatal: true }).decode( + data, + allowIncompleteSuffix ? { stream: true } : undefined, + ); + } catch { + throw new Error('Project file is not valid UTF-8 text'); + } +} + +async function fallbackFileList(projectPath: string): Promise { + const root = path.resolve(projectPath); + const files: string[] = []; + const pending = ['']; + while (pending.length > 0 && files.length < MAX_DISCOVERED_FILES) { + const relativeDirectory = pending.shift() as string; + const target = relativeDirectory + ? path.join(root, ...relativeDirectory.split('/')) + : root; + let entries; + try { + entries = await readdir(target, { withFileTypes: true }); + } catch { + continue; + } + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + const relativePath = relativeDirectory + ? `${relativeDirectory}/${entry.name}` + : entry.name; + if (entry.isDirectory()) { + if (!SKIPPED_DIRECTORIES.has(entry.name)) pending.push(relativePath); + } else if (entry.isFile()) { + files.push(relativePath); + if (files.length >= MAX_DISCOVERED_FILES) break; + } + } + } + return files; +} + +export class CodingProjectFileService { + constructor( + private readonly git: ConversationGitAdapter = new ProcessConversationGitAdapter(), + ) {} + + async status(projectPath: string): Promise { + const result = await this.gitResult(projectPath, [ + 'status', + '--porcelain=v2', + '-z', + '--untracked-files=all', + '--', + '.', + ]); + return result?.code === 0 ? parseStatus(result.stdout) : []; + } + + async find( + projectPath: string, + query: string, + requestedLimit = DEFAULT_FIND_RESULTS, + ): Promise { + const normalizedQuery = query.trim().toLocaleLowerCase(); + if (!normalizedQuery) throw new Error('File query is required'); + if (normalizedQuery.length > 200) throw new Error('File query is too long'); + const limit = Math.min( + Number.isSafeInteger(requestedLimit) && requestedLimit > 0 ? requestedLimit : DEFAULT_FIND_RESULTS, + MAX_FIND_RESULTS, + ); + const paths = await this.listFiles(projectPath); + const matches = paths + .filter((filePath) => filePath.toLocaleLowerCase().includes(normalizedQuery)) + .sort((left, right) => { + const leftName = path.posix.basename(left).toLocaleLowerCase(); + const rightName = path.posix.basename(right).toLocaleLowerCase(); + const leftPrefix = leftName.startsWith(normalizedQuery) ? 0 : 1; + const rightPrefix = rightName.startsWith(normalizedQuery) ? 0 : 1; + return leftPrefix - rightPrefix || left.length - right.length || left.localeCompare(right); + }) + .slice(0, limit); + return await Promise.all(matches.map(async (filePath) => { + let size: number | undefined; + try { + const metadata = await lstat(projectTarget(projectPath, filePath)); + if (metadata.isFile()) size = metadata.size; + } catch { + // A concurrently removed result remains useful by path. + } + return { + path: filePath, + name: path.posix.basename(filePath), + type: 'file' as const, + ...(size === undefined ? {} : { size }), + }; + })); + } + + async content(projectPath: string, requestedPath: string): Promise { + const relativePath = normalizeRelativePath(requestedPath); + const result = await readBoundedFile(projectPath, relativePath, MAX_CONTENT_BYTES); + return { + path: relativePath, + content: decodeText(result.data, result.truncated), + truncated: result.truncated, + }; + } + + async search(projectPath: string, pattern: string): Promise { + const needle = pattern.trim(); + if (!needle) throw new Error('Search pattern is required'); + if (needle.length > 512) throw new Error('Search pattern is too long'); + const foldedNeedle = needle.toLocaleLowerCase(); + const matches: CodingTextSearchResult[] = []; + for (const filePath of await this.listFiles(projectPath)) { + if (matches.length >= MAX_SEARCH_RESULTS) break; + let content: string; + try { + const bounded = await readBoundedFile(projectPath, filePath, MAX_SEARCH_FILE_BYTES); + if (bounded.truncated) continue; + content = decodeText(bounded.data); + } catch { + continue; + } + const lines = content.split(/\r?\n/); + for (let lineIndex = 0; lineIndex < lines.length && matches.length < MAX_SEARCH_RESULTS; lineIndex += 1) { + const line = lines[lineIndex]; + const foldedLine = line.toLocaleLowerCase(); + const first = foldedLine.indexOf(foldedNeedle); + if (first < 0) continue; + const windowStart = Math.max(0, first - 256); + const lineText = line.slice(windowStart, windowStart + MAX_SEARCH_LINE_CHARS); + const foldedText = lineText.toLocaleLowerCase(); + const submatches = []; + let offset = 0; + while (submatches.length < 20) { + const start = foldedText.indexOf(foldedNeedle, offset); + if (start < 0) break; + const end = start + needle.length; + submatches.push({ text: lineText.slice(start, end), start, end }); + offset = Math.max(end, start + 1); + } + matches.push({ + path: filePath, + name: path.posix.basename(filePath), + lineNumber: lineIndex + 1, + lineText, + submatches, + }); + } + } + return matches; + } + + private async listFiles(projectPath: string): Promise { + const repository = await this.gitResult(projectPath, ['rev-parse', '--is-inside-work-tree']); + if (repository?.code === 0 && repository.stdout.trim() === 'true') { + const listed = await this.gitResult(projectPath, [ + 'ls-files', + '-co', + '--exclude-standard', + '-z', + '--', + '.', + ]); + if (listed?.code === 0) { + return [...new Set(listed.stdout + .split('\0') + .filter(Boolean) + .map(normalizeRelativePath))] + .sort() + .slice(0, MAX_DISCOVERED_FILES); + } + } + return await fallbackFileList(projectPath); + } + + private async gitResult(projectPath: string, args: readonly string[]): Promise { + try { + return await this.git.run(projectPath, args); + } catch { + return null; + } + } +} diff --git a/electron/coding-projects/skill-registry.ts b/electron/coding-projects/skill-registry.ts index 2ede69f..b4bd7af 100644 --- a/electron/coding-projects/skill-registry.ts +++ b/electron/coding-projects/skill-registry.ts @@ -4,21 +4,17 @@ import { BUNDLED_CODING_SKILL_IDS, type BundledCodingSkillId, } from '../../shared/coding-skills'; +import type { + ProductCodingCommand, + ProductCodingSkill, + ProductPiCommandInput, +} from '../../shared/coding-product-tools'; -export interface ProductCodingSkill { - id: BundledCodingSkillId; - name: string; - description: string; - selected: boolean; -} - -export interface ProductCodingCommand { - name: string; - title: string; - description: string; - source: 'makelore' | 'pi' | 'skill'; - skillId?: BundledCodingSkillId; -} +export type { + ProductCodingCommand, + ProductCodingSkill, + ProductPiCommandInput, +} from '../../shared/coding-product-tools'; const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [ { name: 'models', title: '切换模型', description: '切换当前会话后续轮次使用的模型', source: 'makelore' }, @@ -28,6 +24,8 @@ const MAKELORE_COMMANDS: readonly ProductCodingCommand[] = [ { name: 'recover', title: '恢复会话', description: '重新创建当前会话 worker 并恢复状态', source: 'makelore' }, ] as const; +const COMMAND_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/; + function frontmatterScalar(content: string, key: string): string | undefined { const block = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)?.[1]; if (!block) return undefined; @@ -70,14 +68,15 @@ export async function listProductCodingSkills( export function buildProductCodingCommandCatalog( skills: readonly ProductCodingSkill[], - piCommands: readonly { name: string; description?: string }[] = [], + piCommands: readonly ProductPiCommandInput[] = [], ): ProductCodingCommand[] { const commands = [...MAKELORE_COMMANDS]; - const used = new Set(commands.map((command) => command.name)); + const used = new Set(commands.map((command) => command.name.toLocaleLowerCase())); for (const command of piCommands) { const name = command.name.trim(); - if (!name || /\s/u.test(name) || used.has(name)) continue; - used.add(name); + const key = name.toLocaleLowerCase(); + if (!COMMAND_NAME_PATTERN.test(name) || used.has(key)) continue; + used.add(key); commands.push({ name, title: name, @@ -86,8 +85,9 @@ export function buildProductCodingCommandCatalog( }); } for (const skill of skills) { - if (!skill.selected || used.has(skill.id)) continue; - used.add(skill.id); + const key = skill.id.toLocaleLowerCase(); + if (!skill.selected || used.has(key)) continue; + used.add(key); commands.push({ name: skill.id, title: skill.name, diff --git a/electron/coding-runtime/pi/product-tools.ts b/electron/coding-runtime/pi/product-tools.ts index bbee574..c3aed81 100644 --- a/electron/coding-runtime/pi/product-tools.ts +++ b/electron/coding-runtime/pi/product-tools.ts @@ -8,6 +8,11 @@ import { buildProductCodingCommandCatalog, listProductCodingSkills, } from '../../coding-projects/skill-registry'; +import type { + ProductCodingCommand, + ProductCodingSkill, + ProductPiCommandInput, +} from '../../../shared/coding-product-tools'; import type { KnownToolDetails, RuntimeContextDetailsV1 } from '../contracts'; import { PiAgentBrowserTool } from './extensions/agent-browser'; import { reportChangedFiles } from './extensions/changed-file'; @@ -65,6 +70,17 @@ export class PiProductTools { return this.changeTracker.getSnapshot(conversationId); } + listSkills(skillIds: readonly string[]): Promise { + return listProductCodingSkills(this.options.bundledSkillsDir, skillIds); + } + + async listCommands( + skillIds: readonly string[], + piCommands: readonly ProductPiCommandInput[] = [], + ): Promise { + return buildProductCodingCommandCatalog(await this.listSkills(skillIds), piCommands); + } + async markBash(conversationId: string, runId: string): Promise { await this.changeTracker.markProjectRefresh(conversationId, runId); } @@ -92,7 +108,7 @@ export class PiProductTools { return await reportChangedFiles(this.changeTracker, context, input); } if (toolName !== 'runtime_context') throw new Error('Product tool is unavailable'); - const skills = await listProductCodingSkills(this.options.bundledSkillsDir, context.skillIds); + const skills = await this.listSkills(context.skillIds); const details: RuntimeContextDetailsV1 = { schema: 'runtime-context.v1', skills, diff --git a/electron/main/index.ts b/electron/main/index.ts index d1584c8..db95cd8 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -95,6 +95,10 @@ import { } from '../image-workspace/local-workspace'; import { WorksSquareDesignWorkspace } from '../image-workspace/works-square-workspace'; import type { DesignWorkspaceModule } from '../image-workspace/module'; +import { CodingAttachmentStore } from '../coding-projects/attachment-store'; +import { PiProductTools } from '../coding-runtime/pi/product-tools'; +import { resolveBundledCodingSkillsDir } from '../coding-runtime/pi/resource-loader'; +import { createCodingProductHost } from '../api/coding-product-services'; // Diagnostic package: force Chromium networking onto HTTP/1.1 for transport A/B testing. app.commandLine.appendSwitch('disable-http2'); @@ -549,6 +553,29 @@ async function initialize(): Promise { window.webContents.send('release-job:status', status); } }); + const codingAttachments = new CodingAttachmentStore( + join(app.getPath('userData'), 'coding-runtime', 'attachments'), + ); + const piProductTools = new PiProductTools({ + browser: agentBrowser, + attachments: codingAttachments, + bundledSkillsDir: resolveBundledCodingSkillsDir({ + isPackaged: app.isPackaged, + resourcesPath: process.resourcesPath, + appPath: app.getAppPath(), + }), + }); + const codingProducts = { + attachments: codingAttachments, + productTools: piProductTools, + host: createCodingProductHost({ + productTools: piProductTools, + getActiveProject: async () => { + const project = await opencodeProjectStore.getActiveProject(); + return project ? { id: project.id, path: project.path } : null; + }, + }), + }; const hostApiContext: HostApiContext = { opencodeManager, opencodeProjectStore, @@ -559,6 +586,7 @@ async function initialize(): Promise { imageWorkspace, lifecycle: backgroundLifecycle, releaseJobs, + codingProducts, }; registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext); diff --git a/shared/coding-product-tools.ts b/shared/coding-product-tools.ts new file mode 100644 index 0000000..76eb292 --- /dev/null +++ b/shared/coding-product-tools.ts @@ -0,0 +1,100 @@ +import type { BundledCodingSkillId } from './coding-skills'; + +export type CodingProjectFileStatus = + | 'added' + | 'modified' + | 'deleted' + | 'renamed' + | 'untracked'; + +export interface CodingProjectFileEntry { + path: string; + name: string; + type: 'file'; + status?: CodingProjectFileStatus; + size?: number; +} + +export interface CodingProjectFileContent { + path: string; + content: string; + truncated: boolean; +} + +export interface CodingTextSearchSubmatch { + text: string; + start: number; + end: number; +} + +export interface CodingTextSearchResult { + path: string; + name: string; + lineNumber: number; + lineText: string; + submatches: CodingTextSearchSubmatch[]; +} + +export interface ConversationChangedFile { + path: string; + status: CodingProjectFileStatus; + diff?: string; + preview?: string; + truncated?: boolean; +} + +export interface ConversationChangesSnapshot { + conversationId: string; + runId: string; + git: boolean; + baselineHead: string | null; + files: ConversationChangedFile[]; +} + +export interface ProductCodingSkill { + id: BundledCodingSkillId; + name: string; + description: string; + selected: boolean; +} + +export interface ProductCodingCommand { + name: string; + title: string; + description: string; + source: 'makelore' | 'pi' | 'skill'; + skillId?: BundledCodingSkillId; +} + +export interface ProductPiCommandInput { + name: string; + description?: string; +} + +export interface CodingFileStatusResponse { + files: CodingProjectFileEntry[]; +} + +export interface CodingFileFindResponse { + files: CodingProjectFileEntry[]; +} + +export interface CodingFileContentResponse { + file: CodingProjectFileContent; +} + +export interface CodingTextSearchResponse { + matches: CodingTextSearchResult[]; +} + +export interface CodingSkillsResponse { + skills: ProductCodingSkill[]; +} + +export interface CodingCommandsResponse { + commands: ProductCodingCommand[]; +} + +export interface CodingChangesResponse { + changes: ConversationChangesSnapshot | null; +} diff --git a/src/lib/coding-product-tools.ts b/src/lib/coding-product-tools.ts new file mode 100644 index 0000000..15fecf1 --- /dev/null +++ b/src/lib/coding-product-tools.ts @@ -0,0 +1,56 @@ +import type { + CodingFileContentResponse, + CodingFileFindResponse, + CodingFileStatusResponse, + CodingTextSearchResponse, + CodingChangesResponse, + CodingCommandsResponse, + CodingSkillsResponse, +} from '../../shared/coding-product-tools'; +import { hostApiFetch } from './host-api'; + +export async function getCodingFileStatus(): Promise { + return await hostApiFetch('/api/coding/files/status'); +} + +export async function findCodingFiles( + query: string, + limit?: number, +): Promise { + const params = new URLSearchParams({ query }); + if (limit !== undefined) params.set('limit', String(limit)); + return await hostApiFetch(`/api/coding/files/find?${params.toString()}`); +} + +export async function getCodingFileContent(path: string): Promise { + const params = new URLSearchParams({ path }); + return await hostApiFetch(`/api/coding/files/content?${params.toString()}`); +} + +export async function searchCodingText(pattern: string): Promise { + const params = new URLSearchParams({ pattern }); + return await hostApiFetch(`/api/coding/search?${params.toString()}`); +} + +export async function getCodingSkills(agentId?: string): Promise { + const params = new URLSearchParams(); + if (agentId) params.set('agentId', agentId); + const query = params.toString(); + return await hostApiFetch(`/api/coding/skills${query ? `?${query}` : ''}`); +} + +export async function getCodingConversationCommands( + conversationId: string, +): Promise { + return await hostApiFetch( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/commands`, + ); +} + +export async function getCodingConversationChanges( + conversationId: string, +): Promise { + return await hostApiFetch( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/changes`, + ); +} diff --git a/tests/electron-runtime/coding-files-host.test.ts b/tests/electron-runtime/coding-files-host.test.ts new file mode 100644 index 0000000..dee1a69 --- /dev/null +++ b/tests/electron-runtime/coding-files-host.test.ts @@ -0,0 +1,41 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { CodingProductHost } from '../../electron/api/coding-product-services'; +import type { HostApiContext } from '../../electron/api/context'; +import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher'; +import { CodingProjectFileService } from '../../electron/coding-projects/project-files'; + +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +describe('PI-105 Electron Host API seam', () => { + it('reads a project-relative file through the Main dispatcher without loopback or path leakage', async () => { + const root = await mkdtemp(path.join(tmpdir(), 'makelore-pi-host-dispatch-')); + roots.push(root); + await writeFile(path.join(root, 'notes.txt'), 'Main-owned content\n', 'utf8'); + const files = new CodingProjectFileService(); + const host = { + fileContent: async (filePath: string) => await files.content(root, filePath), + } as CodingProductHost; + const ctx = { + codingProducts: { host, attachments: {}, productTools: {} }, + } as HostApiContext; + + const response = await dispatchHostApiRequest(ctx, { + path: '/api/coding/files/content?path=notes.txt', + }); + expect(response).toMatchObject({ + status: 200, + ok: true, + json: { + file: { path: 'notes.txt', content: 'Main-owned content\n', truncated: false }, + }, + }); + expect(JSON.stringify(response.json)).not.toContain(root); + }); +}); diff --git a/tests/unit/coding-files-routes.test.ts b/tests/unit/coding-files-routes.test.ts new file mode 100644 index 0000000..6b9168b --- /dev/null +++ b/tests/unit/coding-files-routes.test.ts @@ -0,0 +1,102 @@ +// @vitest-environment node + +import path from 'node:path'; +import { describe, expect, it, vi } from 'vitest'; +import type { CodingProductHost } from '../../electron/api/coding-product-services'; +import type { HostApiContext } from '../../electron/api/context'; +import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher'; + +function context(host?: CodingProductHost): HostApiContext { + return (host + ? { codingProducts: { host, attachments: {}, productTools: {} } } + : {}) as HostApiContext; +} + +function productHost(): CodingProductHost { + return { + fileStatus: vi.fn(async () => [{ + path: 'src/app.ts', name: 'app.ts', type: 'file' as const, status: 'modified' as const, + }]), + findFiles: vi.fn(async () => [{ + path: 'src/app.ts', name: 'app.ts', type: 'file' as const, size: 12, + }]), + fileContent: vi.fn(async () => ({ + path: 'src/app.ts', content: 'export {};\n', truncated: false, + })), + searchText: vi.fn(async () => [{ + path: 'src/app.ts', name: 'app.ts', lineNumber: 1, lineText: 'export {};', submatches: [], + }]), + listSkills: vi.fn(async () => [{ + id: 'agent-browser' as const, name: 'Agent Browser', description: 'Browser', selected: true, + }]), + listCommands: vi.fn(async () => [{ + name: 'compact', title: '压缩会话', description: 'Compact', source: 'makelore' as const, + }]), + getChanges: vi.fn(async () => ({ + conversationId: 'conversation/a', runId: 'run-a', git: false, baselineHead: null, files: [], + })), + }; +} + +describe('PI-105 coding product routes', () => { + it('dispatches the seven vendor-neutral GET routes with stable DTOs', async () => { + const host = productHost(); + const cases = [ + ['/api/coding/files/status', { files: [expect.objectContaining({ path: 'src/app.ts' })] }], + ['/api/coding/files/find?query=app&limit=2', { files: [expect.objectContaining({ size: 12 })] }], + ['/api/coding/files/content?path=src%2Fapp.ts', { file: expect.objectContaining({ content: 'export {};\n' }) }], + ['/api/coding/search?pattern=export', { matches: [expect.objectContaining({ lineNumber: 1 })] }], + ['/api/coding/skills?agentId=builder', { skills: [expect.objectContaining({ selected: true })] }], + ['/api/coding/conversations/conversation%2Fa/commands', { + commands: [expect.objectContaining({ name: 'compact', source: 'makelore' })], + }], + ['/api/coding/conversations/conversation%2Fa/changes', { + changes: expect.objectContaining({ conversationId: 'conversation/a', runId: 'run-a' }), + }], + ] as const; + + for (const [route, expected] of cases) { + const response = await dispatchHostApiRequest(context(host), { path: route }); + expect(response).toMatchObject({ status: 200, ok: true, json: expected }); + const serialized = JSON.stringify(response.json); + expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/); + } + expect(host.findFiles).toHaveBeenCalledWith('app', 2); + expect(host.fileContent).toHaveBeenCalledWith('src/app.ts'); + expect(host.listSkills).toHaveBeenCalledWith('builder'); + expect(host.listCommands).toHaveBeenCalledWith('conversation/a'); + expect(host.getChanges).toHaveBeenCalledWith('conversation/a'); + }); + + it('reports unavailable composition and redacts absolute-path service failures', async () => { + expect(await dispatchHostApiRequest(context(), { path: '/api/coding/files/status' })) + .toMatchObject({ + status: 503, + json: { success: false, code: 'CODING_PRODUCT_TOOLS_UNAVAILABLE' }, + }); + + const host = productHost(); + const absolute = path.resolve('private', 'secret.txt'); + vi.mocked(host.fileContent).mockRejectedValueOnce( + Object.assign(new Error(`ENOENT: ${absolute}`), { code: 'ENOENT' }), + ); + const response = await dispatchHostApiRequest(context(host), { + path: '/api/coding/files/content?path=missing.txt', + }); + expect(response).toMatchObject({ + status: 404, + json: { success: false, code: 'CODING_FILE_NOT_FOUND', error: 'Project file does not exist' }, + }); + expect(JSON.stringify(response.json)).not.toContain(absolute); + }); + + it('does not claim non-GET methods or unrelated coding routes', async () => { + expect(await dispatchHostApiRequest(context(productHost()), { + path: '/api/coding/files/status', method: 'POST', + headers: { 'content-type': 'application/json' }, body: '{}', + })).toMatchObject({ status: 404 }); + expect(await dispatchHostApiRequest(context(productHost()), { + path: '/api/coding/conversations/conversation-a/prompt', + })).toMatchObject({ status: 404 }); + }); +}); diff --git a/tests/unit/coding-product-services.test.ts b/tests/unit/coding-product-services.test.ts new file mode 100644 index 0000000..cbe448b --- /dev/null +++ b/tests/unit/coding-product-services.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment node + +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import type { AgentBrowserModule } from '../../electron/agent-browser'; +import { + CodingProductHostError, + createCodingProductHost, +} from '../../electron/api/coding-product-services'; +import { CodingAttachmentStore } from '../../electron/coding-projects/attachment-store'; +import { createCodingConversationStore } from '../../electron/coding-projects/conversation-store'; +import { + createCodingProjectAgent, + createCodingProjectMetadata, +} from '../../electron/coding-projects/project-config'; +import { PiProductTools } from '../../electron/coding-runtime/pi/product-tools'; + +const roots: string[] = []; +const conversationId = '11111111-1111-4111-8111-111111111111'; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function temporaryRoot(prefix: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +async function configuredProject(): Promise { + const root = await temporaryRoot('makelore-pi-products-'); + await createCodingProjectMetadata(root, { now: '2026-08-23T00:00:00.000Z' }); + await createCodingProjectAgent(root, { + id: 'builder', + avatarId: 'avatar-01', + roleName: '实现者', + name: 'Builder', + model: null, + modelResolution: 'required', + skillIds: ['agent-browser', 'grilling'], + responsibility: { + mission: 'Implement changes', owns: [], boundaries: [], collaborators: [], principles: [], + }, + }, { now: '2026-08-23T00:00:00.000Z' }); + await createCodingConversationStore(root, { + createId: () => conversationId, + now: () => '2026-08-23T00:00:00.000Z', + }).create({ + agentId: 'builder', title: 'PI-105', model: null, modelResolution: 'required', + }); + return root; +} + +function productTools(root: string): PiProductTools { + return new PiProductTools({ + browser: {} as AgentBrowserModule, + attachments: new CodingAttachmentStore(path.join(root, 'attachments')), + bundledSkillsDir: path.resolve('resources/coding-skills'), + }); +} + +describe('PI-105 product Host composition', () => { + it('projects managed skills and a stable command catalog without raw Pi fields', async () => { + const root = await configuredProject(); + const tools = productTools(root); + const host = createCodingProductHost({ + getActiveProject: async () => ({ id: 'project-a', path: root }), + productTools: tools, + listPiCommands: async () => ({ + commands: [ + { name: 'custom', description: 'Custom Pi command', token: 'pi-secret' }, + { name: 'compact', description: 'Must not shadow Makelore' }, + { name: '../unsafe', description: 'Invalid command' }, + ], + apiKey: 'raw-provider-secret', + }), + }); + + const skills = await host.listSkills('builder'); + expect(skills.filter(({ selected }) => selected).map(({ id }) => id)).toEqual([ + 'agent-browser', 'grilling', + ]); + const commands = await host.listCommands(conversationId); + expect(commands.slice(0, 5).map(({ source }) => source)).toEqual([ + 'makelore', 'makelore', 'makelore', 'makelore', 'makelore', + ]); + expect(commands).toContainEqual(expect.objectContaining({ name: 'custom', source: 'pi' })); + expect(commands).toContainEqual(expect.objectContaining({ name: 'agent-browser', source: 'skill' })); + expect(commands.filter(({ name }) => name === 'compact')).toHaveLength(1); + const serialized = JSON.stringify({ skills, commands }); + expect(serialized).not.toContain(root); + expect(serialized).not.toContain('pi-secret'); + expect(serialized).not.toContain('raw-provider-secret'); + expect(serialized).not.toMatch(/"(todo|share|revert|unrevert)"/); + }); + + it('reads exact-run changes from the same PiProductTools tracker instance', async () => { + const root = await configuredProject(); + await writeFile(path.join(root, 'notes.txt'), 'baseline\n', 'utf8'); + const tools = productTools(root); + const host = createCodingProductHost({ + getActiveProject: async () => ({ id: 'project-a', path: root }), + productTools: tools, + }); + await tools.beginRun({ conversationId, runId: 'run-a', projectPath: root }); + await writeFile(path.join(root, 'notes.txt'), 'changed\n', 'utf8'); + await tools.recordTouchedPaths(conversationId, 'run-a', ['notes.txt']); + + expect(await host.getChanges(conversationId)).toMatchObject({ + conversationId, runId: 'run-a', git: false, + files: [{ path: 'notes.txt', status: 'modified', preview: 'changed\n' }], + }); + expect(JSON.stringify(await host.getChanges(conversationId))).not.toContain(root); + }); + + it('returns typed project, Agent, and Conversation errors', async () => { + const root = await configuredProject(); + const tools = productTools(root); + const unavailable = createCodingProductHost({ + getActiveProject: async () => null, + productTools: tools, + }); + await expect(unavailable.fileStatus()).rejects.toMatchObject({ + status: 409, code: 'CODING_ACTIVE_PROJECT_REQUIRED', + }); + + const host = createCodingProductHost({ + getActiveProject: async () => ({ id: 'project-a', path: root }), + productTools: tools, + }); + await expect(host.listSkills('missing')).rejects.toMatchObject({ + status: 404, code: 'CODING_AGENT_NOT_FOUND', + }); + await expect(host.listCommands('22222222-2222-4222-8222-222222222222')) + .rejects.toMatchObject({ + status: 404, code: 'CODING_CONVERSATION_NOT_FOUND', + }); + }); +}); diff --git a/tests/unit/coding-product-tools-facade.test.ts b/tests/unit/coding-product-tools-facade.test.ts new file mode 100644 index 0000000..6cc29c2 --- /dev/null +++ b/tests/unit/coding-product-tools-facade.test.ts @@ -0,0 +1,44 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const hostApiFetchMock = vi.hoisted(() => vi.fn()); + +vi.mock('../../src/lib/host-api', () => ({ + hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args), +})); + +import { + findCodingFiles, + getCodingConversationChanges, + getCodingConversationCommands, + getCodingFileContent, + getCodingFileStatus, + getCodingSkills, + searchCodingText, +} from '../../src/lib/coding-product-tools'; + +describe('PI-105 Renderer coding product facade', () => { + beforeEach(() => { + hostApiFetchMock.mockReset().mockResolvedValue({}); + }); + + it('uses only the vendor-neutral coding file and catalog routes', async () => { + await getCodingFileStatus(); + await findCodingFiles('app file', 25); + await getCodingFileContent('src/app file.ts'); + await searchCodingText('hello world'); + await getCodingSkills('builder/one'); + await getCodingConversationCommands('conversation/one'); + await getCodingConversationChanges('conversation/one'); + + expect(hostApiFetchMock.mock.calls.map(([route]) => route)).toEqual([ + '/api/coding/files/status', + '/api/coding/files/find?query=app+file&limit=25', + '/api/coding/files/content?path=src%2Fapp+file.ts', + '/api/coding/search?pattern=hello+world', + '/api/coding/skills?agentId=builder%2Fone', + '/api/coding/conversations/conversation%2Fone/commands', + '/api/coding/conversations/conversation%2Fone/changes', + ]); + expect(JSON.stringify(hostApiFetchMock.mock.calls)).not.toContain('/api/opencode'); + }); +}); diff --git a/tests/unit/coding-project-files.test.ts b/tests/unit/coding-project-files.test.ts new file mode 100644 index 0000000..5eec174 --- /dev/null +++ b/tests/unit/coding-project-files.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node + +import { execFile } from 'node:child_process'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { afterEach, describe, expect, it } from 'vitest'; +import { CodingProjectFileService } from '../../electron/coding-projects/project-files'; + +const exec = promisify(execFile); +const roots: string[] = []; + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function temporaryRoot(prefix: string): Promise { + const root = await mkdtemp(path.join(tmpdir(), prefix)); + roots.push(root); + return root; +} + +async function git(root: string, ...args: string[]): Promise { + await exec('git', ['-C', root, ...args], { windowsHide: true }); +} + +async function initializeRepository(root: string): Promise { + await git(root, 'init'); + await git(root, 'config', 'user.email', 'pi-files@example.invalid'); + await git(root, 'config', 'user.name', 'PI Files'); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "baseline";\n', 'utf8'); + await writeFile(path.join(root, '.gitignore'), 'ignored.txt\n', 'utf8'); + await git(root, 'add', '.'); + await git(root, 'commit', '-m', 'baseline'); +} + +describe('PI-105 project file service', () => { + it('reports Git status and finds tracked or untracked files using project-relative DTOs', async () => { + const root = await temporaryRoot('makelore-pi-files-git-'); + await initializeRepository(root); + await writeFile(path.join(root, 'src', 'app.ts'), 'export const message = "changed";\n', 'utf8'); + await writeFile(path.join(root, 'src', 'app.test.ts'), 'test("changed", () => true);\n', 'utf8'); + await writeFile(path.join(root, 'ignored.txt'), 'ignored\n', 'utf8'); + + const service = new CodingProjectFileService(); + expect(await service.status(root)).toEqual([ + expect.objectContaining({ path: 'src/app.ts', name: 'app.ts', status: 'modified' }), + expect.objectContaining({ path: 'src/app.test.ts', name: 'app.test.ts', status: 'untracked' }), + ]); + const found = await service.find(root, 'app', 1); + expect(found).toHaveLength(1); + expect(found[0]).toMatchObject({ path: 'src/app.ts', name: 'app.ts', type: 'file' }); + expect(JSON.stringify({ status: await service.status(root), found })).not.toContain(root); + expect((await service.find(root, 'ignored')).map(({ path: filePath }) => filePath)).toEqual([]); + }); + + it('returns bounded UTF-8 content and rejects absolute, traversal, and binary reads', async () => { + const root = await temporaryRoot('makelore-pi-files-content-'); + await writeFile(path.join(root, 'notes.txt'), 'hello project\n', 'utf8'); + await writeFile(path.join(root, 'large.txt'), 'x'.repeat((256 * 1024) + 100), 'utf8'); + await writeFile(path.join(root, 'large-unicode.txt'), '你'.repeat(90_000), 'utf8'); + await writeFile(path.join(root, 'binary.bin'), Buffer.from([1, 0, 2])); + const service = new CodingProjectFileService(); + + expect(await service.content(root, 'notes.txt')).toEqual({ + path: 'notes.txt', content: 'hello project\n', truncated: false, + }); + const large = await service.content(root, 'large.txt'); + expect(Buffer.byteLength(large.content, 'utf8')).toBe(256 * 1024); + expect(large.truncated).toBe(true); + const largeUnicode = await service.content(root, 'large-unicode.txt'); + expect(Buffer.byteLength(largeUnicode.content, 'utf8')).toBeLessThanOrEqual(256 * 1024); + expect(largeUnicode.content).toMatch(/^你+$/u); + expect(largeUnicode.truncated).toBe(true); + await expect(service.content(root, '../secret.txt')).rejects.toThrow('escapes the active project'); + await expect(service.content(root, path.resolve(root, 'notes.txt'))).rejects.toThrow('must be relative'); + await expect(service.content(root, 'binary.bin')).rejects.toThrow('Binary project files'); + }); + + it('searches literal text with bounded relative matches and falls back outside Git', async () => { + const root = await temporaryRoot('makelore-pi-files-search-'); + await mkdir(path.join(root, 'docs'), { recursive: true }); + await mkdir(path.join(root, 'node_modules', 'private-package'), { recursive: true }); + await writeFile(path.join(root, 'docs', 'guide.md'), 'First line\nNeedle and needle again\n', 'utf8'); + await writeFile(path.join(root, 'node_modules', 'private-package', 'secret.txt'), 'needle\n', 'utf8'); + const service = new CodingProjectFileService({ + async run() { + throw Object.assign(new Error('spawn git ENOENT'), { code: 'ENOENT' }); + }, + }); + + expect((await service.find(root, 'guide')).map(({ path: filePath }) => filePath)).toEqual([ + 'docs/guide.md', + ]); + const matches = await service.search(root, 'needle'); + expect(matches).toEqual([{ + path: 'docs/guide.md', + name: 'guide.md', + lineNumber: 2, + lineText: 'Needle and needle again', + submatches: [ + { text: 'Needle', start: 0, end: 6 }, + { text: 'needle', start: 11, end: 17 }, + ], + }]); + expect(JSON.stringify(matches)).not.toContain(root); + expect(JSON.stringify(matches)).not.toContain('private-package'); + }); +});