feat(coding): add PI-105 product file host API

This commit is contained in:
brother7 committed 2026-08-23 18:17:30 +08:00
1 parent 7680bfb0cf
commit 9520872d2f
17 files changed
+1419 -38

No files matched your search

@@ -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.
+171
View File
@@ -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<CodingProjectFileEntry[]>;
findFiles(query: string, limit?: number): Promise<CodingProjectFileEntry[]>;
fileContent(path: string): Promise<CodingProjectFileContent>;
searchText(pattern: string): Promise<CodingTextSearchResult[]>;
listSkills(agentId?: string): Promise<ProductCodingSkill[]>;
listCommands(conversationId: string): Promise<ProductCodingCommand[]>;
getChanges(conversationId: string): Promise<ConversationChangesSnapshot | null>;
}
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<ActiveCodingProject | null>;
productTools: PiProductTools;
files?: CodingProjectFileService;
listPiCommands?(conversationId: string): Promise<unknown>;
}
function normalizePiCommands(value: unknown): ProductPiCommandInput[] {
const record = value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: 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<string, unknown>;
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<ActiveCodingProject> {
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<readonly string[]> {
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);
},
};
}
+2
View File
@@ -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<typeof createWorksSubmissionBindingStore>;
@@ -77,4 +78,5 @@ export interface HostApiContext {
imageWorkspace?: DesignWorkspaceModule;
lifecycle?: BackgroundLifecycleController;
releaseJobs?: ReleaseJobManager;
codingProducts?: CodingProductComposition;
}
+2
View File
@@ -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,
+138
View File
@@ -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<boolean> {
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;
}
@@ -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;
+318
View File
@@ -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<string> {
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<string[]> {
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<CodingProjectFileEntry[]> {
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<CodingProjectFileEntry[]> {
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<CodingProjectFileContent> {
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<CodingTextSearchResult[]> {
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<string[]> {
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<GitCommandResult | null> {
try {
return await this.git.run(projectPath, args);
} catch {
return null;
}
}
}
+20 -20
View File
@@ -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,
+17 -1
View File
@@ -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<ProductCodingSkill[]> {
return listProductCodingSkills(this.options.bundledSkillsDir, skillIds);
}
async listCommands(
skillIds: readonly string[],
piCommands: readonly ProductPiCommandInput[] = [],
): Promise<ProductCodingCommand[]> {
return buildProductCodingCommandCatalog(await this.listSkills(skillIds), piCommands);
}
async markBash(conversationId: string, runId: string): Promise<void> {
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,
+28
View File
@@ -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<void> {
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<void> {
imageWorkspace,
lifecycle: backgroundLifecycle,
releaseJobs,
codingProducts,
};
registerIpcHandlers(undefined, opencodeManager, undefined, window, backgroundLifecycle, hostApiContext);
+100
View File
@@ -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;
}
+56
View File
@@ -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<CodingFileStatusResponse> {
return await hostApiFetch<CodingFileStatusResponse>('/api/coding/files/status');
}
export async function findCodingFiles(
query: string,
limit?: number,
): Promise<CodingFileFindResponse> {
const params = new URLSearchParams({ query });
if (limit !== undefined) params.set('limit', String(limit));
return await hostApiFetch<CodingFileFindResponse>(`/api/coding/files/find?${params.toString()}`);
}
export async function getCodingFileContent(path: string): Promise<CodingFileContentResponse> {
const params = new URLSearchParams({ path });
return await hostApiFetch<CodingFileContentResponse>(`/api/coding/files/content?${params.toString()}`);
}
export async function searchCodingText(pattern: string): Promise<CodingTextSearchResponse> {
const params = new URLSearchParams({ pattern });
return await hostApiFetch<CodingTextSearchResponse>(`/api/coding/search?${params.toString()}`);
}
export async function getCodingSkills(agentId?: string): Promise<CodingSkillsResponse> {
const params = new URLSearchParams();
if (agentId) params.set('agentId', agentId);
const query = params.toString();
return await hostApiFetch<CodingSkillsResponse>(`/api/coding/skills${query ? `?${query}` : ''}`);
}
export async function getCodingConversationCommands(
conversationId: string,
): Promise<CodingCommandsResponse> {
return await hostApiFetch<CodingCommandsResponse>(
`/api/coding/conversations/${encodeURIComponent(conversationId)}/commands`,
);
}
export async function getCodingConversationChanges(
conversationId: string,
): Promise<CodingChangesResponse> {
return await hostApiFetch<CodingChangesResponse>(
`/api/coding/conversations/${encodeURIComponent(conversationId)}/changes`,
);
}
@@ -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);
});
});
+102
View File
@@ -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 });
});
});
+142
View File
@@ -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<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
async function configuredProject(): Promise<string> {
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<CodingProductHostError>({
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<CodingProductHostError>({
status: 404, code: 'CODING_AGENT_NOT_FOUND',
});
await expect(host.listCommands('22222222-2222-4222-8222-222222222222'))
.rejects.toMatchObject<CodingProductHostError>({
status: 404, code: 'CODING_CONVERSATION_NOT_FOUND',
});
});
});
@@ -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');
});
});
+111
View File
@@ -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<string> {
const root = await mkdtemp(path.join(tmpdir(), prefix));
roots.push(root);
return root;
}
async function git(root: string, ...args: string[]): Promise<void> {
await exec('git', ['-C', root, ...args], { windowsHide: true });
}
async function initializeRepository(root: string): Promise<void> {
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');
});
});