feat(coding): add model tools and device packages
This commit is contained in:
609
electron/coding-packages/device-package-manager.ts
Normal file
609
electron/coding-packages/device-package-manager.ts
Normal file
@@ -0,0 +1,609 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { cp, mkdir, readFile, rename, rm, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { DefaultPackageManager, SettingsManager } from '@earendil-works/pi-coding-agent';
|
||||
import { atomicWriteJson } from '../coding-projects/atomic-json';
|
||||
import {
|
||||
devicePackageKind,
|
||||
inspectDevicePackage,
|
||||
parseDevicePackageSource,
|
||||
relativeContainedPath,
|
||||
safeStorageSegment,
|
||||
type DevicePackageIndexV1,
|
||||
type DevicePackageRecordV1,
|
||||
type InstallPreviewV1,
|
||||
} from './device-package-format';
|
||||
|
||||
export * from './device-package-format';
|
||||
|
||||
const PLAN_TTL_MS = 10 * 60 * 1000;
|
||||
const INSTALL_OUTPUT_BYTES = 64 * 1024;
|
||||
const INSTALL_TIMEOUT_MS = 2 * 60 * 1000;
|
||||
const INDEX_SCHEMA_VERSION = 1;
|
||||
const EXECUTABLE_WARNING = '此包包含可执行 Pi extension,将获得完整桌面权限,包括当前进程可用的文件、网络和进程权限。';
|
||||
|
||||
export class DevicePackageError extends Error {
|
||||
constructor(
|
||||
readonly code:
|
||||
| 'local_package_source_unsupported'
|
||||
| 'local_package_not_found'
|
||||
| 'local_package_manifest_invalid'
|
||||
| 'local_package_dependency_failed'
|
||||
| 'local_package_confirmation_required'
|
||||
| 'local_package_plan_expired'
|
||||
| 'local_package_install_failed'
|
||||
| 'local_package_not_installed'
|
||||
| 'local_package_in_use',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'DevicePackageError';
|
||||
}
|
||||
}
|
||||
|
||||
export interface DevicePackageInstallInput {
|
||||
source: string;
|
||||
executablePath: string;
|
||||
cliPath: string;
|
||||
npmCliPath?: string;
|
||||
agentDir: string;
|
||||
cwd: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export type DevicePackageInstallRunner = (input: DevicePackageInstallInput) => Promise<void>;
|
||||
|
||||
export interface DevicePackageManagerOptions {
|
||||
rootDir: string;
|
||||
executablePath?: string;
|
||||
cliPath?: string;
|
||||
npmCliPath?: string;
|
||||
runInstall?: DevicePackageInstallRunner;
|
||||
now?: () => number;
|
||||
createId?: () => string;
|
||||
writeIndex?: (filePath: string, value: DevicePackageIndexV1) => Promise<void>;
|
||||
onGenerationChanged?(index: DevicePackageIndexV1): Promise<void> | void;
|
||||
}
|
||||
|
||||
interface StagedPlanV1 {
|
||||
schemaVersion: 1;
|
||||
requestedByTurnId: string;
|
||||
preview: InstallPreviewV1;
|
||||
record: DevicePackageRecordV1;
|
||||
}
|
||||
|
||||
export interface EnabledDevicePackageResources {
|
||||
generation: number;
|
||||
packageIds: string[];
|
||||
packageRefs: Array<{ packageId: string; resolvedVersion: string }>;
|
||||
skillEntries: Array<{ id: string; entryPath: string; packageRoot: string }>;
|
||||
extensionPaths: string[];
|
||||
}
|
||||
|
||||
function installEnvironment(agentDir: string): NodeJS.ProcessEnv {
|
||||
return {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
PI_CODING_AGENT_DIR: agentDir,
|
||||
PI_TELEMETRY: '0',
|
||||
GIT_TERMINAL_PROMPT: '0',
|
||||
GIT_SSH_COMMAND: 'ssh -o BatchMode=yes',
|
||||
CI: '1',
|
||||
npm_config_ignore_scripts: 'true',
|
||||
npm_config_update_notifier: 'false',
|
||||
npm_config_audit: 'false',
|
||||
npm_config_fund: 'false',
|
||||
};
|
||||
}
|
||||
|
||||
export async function runPiPackageInstall(input: DevicePackageInstallInput): Promise<void> {
|
||||
await mkdir(input.agentDir, { recursive: true });
|
||||
await mkdir(input.cwd, { recursive: true });
|
||||
if (input.npmCliPath) {
|
||||
await atomicWriteJson(path.join(input.agentDir, 'settings.json'), {
|
||||
npmCommand: [input.executablePath, input.npmCliPath],
|
||||
});
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
input.executablePath,
|
||||
[input.cliPath, 'install', input.source, '--no-approve'],
|
||||
{
|
||||
cwd: input.cwd,
|
||||
env: input.env,
|
||||
windowsHide: true,
|
||||
timeout: INSTALL_TIMEOUT_MS,
|
||||
maxBuffer: INSTALL_OUTPUT_BYTES,
|
||||
},
|
||||
(error) => error ? reject(error) : resolve(),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function string(value: unknown, field: string, maximum = 2048): string {
|
||||
if (typeof value !== 'string' || !value.trim() || value.length > maximum) {
|
||||
throw new DevicePackageError('local_package_install_failed', `${field} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseRecord(value: unknown): DevicePackageRecordV1 {
|
||||
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.source)
|
||||
|| !Array.isArray(value.skillEntries) || !Array.isArray(value.extensionEntries)
|
||||
|| typeof value.enabled !== 'boolean' || typeof value.confirmedExecutableCode !== 'boolean') {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid');
|
||||
}
|
||||
const sourceKind = value.source.kind;
|
||||
if (sourceKind !== 'npm' && sourceKind !== 'git' && sourceKind !== 'file') {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package source is invalid');
|
||||
}
|
||||
const kind = value.kind;
|
||||
if (kind !== 'skill-only' && kind !== 'pi-extension' && kind !== 'mixed') {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package kind is invalid');
|
||||
}
|
||||
const skillEntries = value.skillEntries.map((entry) => {
|
||||
if (!isRecord(entry)) throw new DevicePackageError('local_package_install_failed', 'Skill entry is invalid');
|
||||
return { id: string(entry.id, 'Skill id', 128), entryPath: string(entry.entryPath, 'Skill path') };
|
||||
});
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
packageId: string(value.packageId, 'Package id', 128),
|
||||
displayName: string(value.displayName, 'Display name', 256),
|
||||
resolvedVersion: string(value.resolvedVersion, 'Resolved version', 128),
|
||||
source: {
|
||||
kind: sourceKind,
|
||||
requested: string(value.source.requested, 'Requested source'),
|
||||
resolved: string(value.source.resolved, 'Resolved source'),
|
||||
},
|
||||
kind,
|
||||
skillEntries,
|
||||
extensionEntries: value.extensionEntries.map((entry) => string(entry, 'Extension path')),
|
||||
enabled: value.enabled,
|
||||
confirmedExecutableCode: value.confirmedExecutableCode,
|
||||
installedAt: string(value.installedAt, 'Installed at', 64),
|
||||
};
|
||||
}
|
||||
|
||||
function parseIndex(value: unknown): DevicePackageIndexV1 {
|
||||
if (!isRecord(value) || value.schemaVersion !== INDEX_SCHEMA_VERSION
|
||||
|| !Number.isSafeInteger(value.generation) || (value.generation as number) < 0
|
||||
|| !Array.isArray(value.packages)) {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid');
|
||||
}
|
||||
const packages = value.packages.map(parseRecord);
|
||||
const ids = new Set<string>();
|
||||
for (const record of packages) {
|
||||
if (ids.has(record.packageId)) {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package index contains duplicate ids');
|
||||
}
|
||||
ids.add(record.packageId);
|
||||
}
|
||||
return { schemaVersion: 1, generation: value.generation as number, packages };
|
||||
}
|
||||
|
||||
function cloneIndex(index: DevicePackageIndexV1): DevicePackageIndexV1 {
|
||||
return structuredClone(index);
|
||||
}
|
||||
|
||||
async function pathMetadata(target: string): Promise<'file' | 'directory' | null> {
|
||||
return await stat(target).then((value) => value.isDirectory() ? 'directory' : value.isFile() ? 'file' : null)
|
||||
.catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
async function gitHead(directory: string): Promise<string> {
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
execFile('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: directory,
|
||||
windowsHide: true,
|
||||
timeout: 10_000,
|
||||
maxBuffer: 4096,
|
||||
}, (error, stdout) => error ? reject(error) : resolve(stdout.trim()));
|
||||
});
|
||||
}
|
||||
|
||||
export class DevicePackageManager {
|
||||
private readonly rootDir: string;
|
||||
private readonly packagesDir: string;
|
||||
private readonly stagingDir: string;
|
||||
private readonly trashDir: string;
|
||||
private readonly indexPath: string;
|
||||
private readonly now: () => number;
|
||||
private readonly createId: () => string;
|
||||
private readonly runInstall: DevicePackageInstallRunner;
|
||||
private readonly writeIndex: (filePath: string, value: DevicePackageIndexV1) => Promise<void>;
|
||||
private readonly activePackages = new Map<string, number>();
|
||||
private readonly pendingCleanup = new Set<string>();
|
||||
private operation: Promise<void> = Promise.resolve();
|
||||
|
||||
constructor(private readonly options: DevicePackageManagerOptions) {
|
||||
this.rootDir = path.resolve(options.rootDir);
|
||||
this.packagesDir = path.join(this.rootDir, 'packages');
|
||||
this.stagingDir = path.join(this.rootDir, 'staging');
|
||||
this.trashDir = path.join(this.rootDir, 'trash');
|
||||
this.indexPath = path.join(this.rootDir, 'index.json');
|
||||
this.now = options.now ?? Date.now;
|
||||
this.createId = options.createId ?? randomUUID;
|
||||
this.runInstall = options.runInstall ?? runPiPackageInstall;
|
||||
this.writeIndex = options.writeIndex ?? atomicWriteJson;
|
||||
}
|
||||
|
||||
async prepare(sourceValue: string, requestedByTurnId: string): Promise<InstallPreviewV1> {
|
||||
if (!requestedByTurnId.trim()) {
|
||||
throw new DevicePackageError('local_package_confirmation_required', 'Request turn id is required');
|
||||
}
|
||||
let source;
|
||||
try {
|
||||
source = parseDevicePackageSource(sourceValue);
|
||||
} catch (error) {
|
||||
throw new DevicePackageError('local_package_source_unsupported', (error as Error).message);
|
||||
}
|
||||
const planId = this.createId();
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Install plan id is invalid');
|
||||
}
|
||||
const planRoot = path.join(this.stagingDir, planId);
|
||||
const packageRoot = path.join(planRoot, 'package');
|
||||
await mkdir(planRoot, { recursive: true });
|
||||
try {
|
||||
let resolvedSource = sourceValue.trim();
|
||||
if (source.kind === 'file') {
|
||||
const metadata = await pathMetadata(source.absolutePath);
|
||||
if (!metadata) throw new DevicePackageError('local_package_not_found', 'Local package source does not exist');
|
||||
if (metadata === 'directory') await cp(source.absolutePath, packageRoot, { recursive: true, force: false });
|
||||
else {
|
||||
await mkdir(packageRoot, { recursive: true });
|
||||
await cp(source.absolutePath, path.join(packageRoot, path.basename(source.absolutePath)), { force: false });
|
||||
}
|
||||
resolvedSource = source.absolutePath;
|
||||
} else {
|
||||
const executablePath = this.options.executablePath;
|
||||
const cliPath = this.options.cliPath;
|
||||
if (!executablePath || !cliPath) {
|
||||
throw new DevicePackageError('local_package_dependency_failed', 'Bundled Pi installer is unavailable');
|
||||
}
|
||||
const agentDir = path.join(planRoot, 'pi-agent');
|
||||
const cwd = path.join(planRoot, 'project');
|
||||
await this.runInstall({
|
||||
source: source.spec,
|
||||
executablePath,
|
||||
cliPath,
|
||||
...(this.options.npmCliPath ? { npmCliPath: this.options.npmCliPath } : {}),
|
||||
agentDir,
|
||||
cwd,
|
||||
env: installEnvironment(agentDir),
|
||||
});
|
||||
const manager = new DefaultPackageManager({
|
||||
cwd,
|
||||
agentDir,
|
||||
settingsManager: SettingsManager.inMemory({}, { projectTrusted: false }),
|
||||
});
|
||||
const installedPath = manager.getInstalledPath(source.spec, 'user');
|
||||
if (!installedPath) throw new DevicePackageError('local_package_dependency_failed', 'Pi did not install the package');
|
||||
if (source.kind === 'npm') {
|
||||
const installRoot = path.join(agentDir, 'npm');
|
||||
await cp(installRoot, packageRoot, { recursive: true, force: false });
|
||||
const relativePackage = relativeContainedPath(installRoot, installedPath);
|
||||
const nested = path.join(packageRoot, ...relativePackage.split('/'));
|
||||
const inspected = await inspectDevicePackage(nested);
|
||||
const prefix = relativeContainedPath(packageRoot, nested);
|
||||
const remapped = {
|
||||
...inspected,
|
||||
skillEntries: inspected.skillEntries.map((entry) => ({
|
||||
...entry, entryPath: `${prefix}/${entry.entryPath}`,
|
||||
})),
|
||||
extensionEntries: inspected.extensionEntries.map((entry) => `${prefix}/${entry}`),
|
||||
};
|
||||
return await this.finishPrepare({
|
||||
planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId,
|
||||
resolvedSource: `npm:${inspected.displayName}@${inspected.resolvedVersion ?? 'unknown'}`,
|
||||
inspected: remapped,
|
||||
});
|
||||
}
|
||||
await cp(installedPath, packageRoot, { recursive: true, force: false });
|
||||
const commit = await gitHead(installedPath).catch(() => {
|
||||
throw new DevicePackageError('local_package_dependency_failed', 'Git package commit cannot be resolved');
|
||||
});
|
||||
resolvedSource = `${source.spec.replace(/@[^/@]+$/u, '')}@${commit}`;
|
||||
}
|
||||
return await this.finishPrepare({
|
||||
planId, planRoot, packageRoot, sourceValue, source, requestedByTurnId, resolvedSource,
|
||||
inspected: await inspectDevicePackage(
|
||||
packageRoot,
|
||||
source.kind === 'file'
|
||||
? path.basename(source.absolutePath, path.extname(source.absolutePath))
|
||||
: path.basename(packageRoot),
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
await rm(planRoot, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (error instanceof DevicePackageError) throw error;
|
||||
throw new DevicePackageError('local_package_manifest_invalid', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private async finishPrepare(input: {
|
||||
planId: string;
|
||||
planRoot: string;
|
||||
packageRoot: string;
|
||||
sourceValue: string;
|
||||
source: ReturnType<typeof parseDevicePackageSource>;
|
||||
requestedByTurnId: string;
|
||||
resolvedSource: string;
|
||||
inspected: Awaited<ReturnType<typeof inspectDevicePackage>>;
|
||||
}): Promise<InstallPreviewV1> {
|
||||
const includesExecutableCode = input.inspected.extensionEntries.length > 0;
|
||||
const resolvedVersion = input.inspected.resolvedVersion
|
||||
?? `local-${new Date(this.now()).toISOString().replace(/[-:.]/gu, '')}`;
|
||||
safeStorageSegment(resolvedVersion);
|
||||
const preview: InstallPreviewV1 = {
|
||||
schemaVersion: 1,
|
||||
planId: input.planId,
|
||||
expiresAt: new Date(this.now() + PLAN_TTL_MS).toISOString(),
|
||||
requestedSource: input.sourceValue.trim(),
|
||||
resolvedSource: input.resolvedSource,
|
||||
packageId: input.inspected.packageId,
|
||||
displayName: input.inspected.displayName,
|
||||
resolvedVersion,
|
||||
kind: devicePackageKind(input.inspected),
|
||||
skillEntries: input.inspected.skillEntries,
|
||||
extensionEntries: input.inspected.extensionEntries,
|
||||
includesExecutableCode,
|
||||
ignoredLifecycleScripts: input.inspected.ignoredLifecycleScripts,
|
||||
warnings: includesExecutableCode ? [EXECUTABLE_WARNING] : [],
|
||||
scope: 'device-parent-workers',
|
||||
};
|
||||
const record: DevicePackageRecordV1 = {
|
||||
schemaVersion: 1,
|
||||
packageId: preview.packageId,
|
||||
displayName: preview.displayName,
|
||||
resolvedVersion,
|
||||
source: {
|
||||
kind: input.source.kind,
|
||||
requested: preview.requestedSource,
|
||||
resolved: preview.resolvedSource,
|
||||
},
|
||||
kind: preview.kind,
|
||||
skillEntries: preview.skillEntries,
|
||||
extensionEntries: preview.extensionEntries,
|
||||
enabled: true,
|
||||
confirmedExecutableCode: includesExecutableCode,
|
||||
installedAt: new Date(this.now()).toISOString(),
|
||||
};
|
||||
const plan: StagedPlanV1 = {
|
||||
schemaVersion: 1,
|
||||
requestedByTurnId: input.requestedByTurnId,
|
||||
preview,
|
||||
record,
|
||||
};
|
||||
await atomicWriteJson(path.join(input.planRoot, 'plan.json'), plan);
|
||||
return structuredClone(preview);
|
||||
}
|
||||
|
||||
async commit(planId: string, confirmed: true, confirmationTurnId: string): Promise<DevicePackageIndexV1> {
|
||||
if (confirmed !== true || !confirmationTurnId.trim()) {
|
||||
throw new DevicePackageError('local_package_confirmation_required', 'Literal confirmation is required');
|
||||
}
|
||||
return await this.withOperation(async () => {
|
||||
const plan = await this.readPlan(planId);
|
||||
if (plan.requestedByTurnId === confirmationTurnId) {
|
||||
throw new DevicePackageError('local_package_confirmation_required', 'Confirm installation in a new user turn');
|
||||
}
|
||||
if (Date.parse(plan.preview.expiresAt) <= this.now()) {
|
||||
throw new DevicePackageError('local_package_plan_expired', 'Install preview has expired');
|
||||
}
|
||||
if (plan.preview.includesExecutableCode && !plan.preview.warnings.includes(EXECUTABLE_WARNING)) {
|
||||
throw new DevicePackageError('local_package_confirmation_required', 'Executable permission warning is missing');
|
||||
}
|
||||
const stagedPackage = path.join(this.stagingDir, planId, 'package');
|
||||
await this.assertRecordEntries(stagedPackage, plan.record);
|
||||
const index = await this.readIndex();
|
||||
const existing = index.packages.find(({ packageId }) => packageId === plan.record.packageId);
|
||||
if (existing && existing.source.requested !== plan.record.source.requested) {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Package id belongs to another source');
|
||||
}
|
||||
const destination = this.packageDirectory(plan.record);
|
||||
const activeKey = this.packageKey(plan.record);
|
||||
if (await pathMetadata(destination) && (this.activePackages.get(activeKey) ?? 0) > 0) {
|
||||
throw new DevicePackageError('local_package_in_use', 'This package version is active in a worker');
|
||||
}
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
await mkdir(this.trashDir, { recursive: true });
|
||||
const displaced = await pathMetadata(destination)
|
||||
? path.join(this.trashDir, `${plan.record.packageId}-${this.createId()}`)
|
||||
: null;
|
||||
if (displaced) await rename(destination, displaced);
|
||||
let moved = false;
|
||||
try {
|
||||
await rename(stagedPackage, destination);
|
||||
moved = true;
|
||||
const next: DevicePackageIndexV1 = {
|
||||
schemaVersion: 1,
|
||||
generation: index.generation + 1,
|
||||
packages: [...index.packages.filter(({ packageId }) => packageId !== plan.record.packageId), plan.record]
|
||||
.sort((left, right) => left.packageId.localeCompare(right.packageId)),
|
||||
};
|
||||
await this.writeIndex(this.indexPath, next);
|
||||
await rm(path.join(this.stagingDir, planId), { recursive: true, force: true });
|
||||
if (existing && existing.resolvedVersion !== plan.record.resolvedVersion) {
|
||||
await this.cleanupPackage(existing);
|
||||
}
|
||||
if (displaced) await rm(displaced, { recursive: true, force: true }).catch(() => undefined);
|
||||
await this.options.onGenerationChanged?.(cloneIndex(next));
|
||||
return cloneIndex(next);
|
||||
} catch (error) {
|
||||
if (moved) await rm(destination, { recursive: true, force: true }).catch(() => undefined);
|
||||
if (displaced) await rename(displaced, destination).catch(() => undefined);
|
||||
if (error instanceof DevicePackageError) throw error;
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package commit failed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async list(): Promise<DevicePackageIndexV1> {
|
||||
return cloneIndex(await this.readIndex());
|
||||
}
|
||||
|
||||
async setEnabled(packageId: string, enabled: boolean): Promise<DevicePackageIndexV1> {
|
||||
return await this.withOperation(async () => {
|
||||
const index = await this.readIndex();
|
||||
const existing = index.packages.find((record) => record.packageId === packageId);
|
||||
if (!existing) throw new DevicePackageError('local_package_not_installed', 'Device package is not installed');
|
||||
if (existing.enabled === enabled) return cloneIndex(index);
|
||||
const next: DevicePackageIndexV1 = {
|
||||
schemaVersion: 1,
|
||||
generation: index.generation + 1,
|
||||
packages: index.packages.map((record) => record.packageId === packageId
|
||||
? { ...record, enabled }
|
||||
: record),
|
||||
};
|
||||
await this.writeIndex(this.indexPath, next);
|
||||
await this.options.onGenerationChanged?.(cloneIndex(next));
|
||||
return cloneIndex(next);
|
||||
});
|
||||
}
|
||||
|
||||
async uninstall(packageId: string): Promise<DevicePackageIndexV1> {
|
||||
return await this.withOperation(async () => {
|
||||
const index = await this.readIndex();
|
||||
const existing = index.packages.find((record) => record.packageId === packageId);
|
||||
if (!existing) return cloneIndex(index);
|
||||
const next: DevicePackageIndexV1 = {
|
||||
schemaVersion: 1,
|
||||
generation: index.generation + 1,
|
||||
packages: index.packages.filter((record) => record.packageId !== packageId),
|
||||
};
|
||||
await this.writeIndex(this.indexPath, next);
|
||||
await this.cleanupPackage(existing);
|
||||
await this.options.onGenerationChanged?.(cloneIndex(next));
|
||||
return cloneIndex(next);
|
||||
});
|
||||
}
|
||||
|
||||
async resolveEnabledResources(): Promise<EnabledDevicePackageResources> {
|
||||
const index = await this.readIndex();
|
||||
const enabled = index.packages.filter(({ enabled }) => enabled);
|
||||
const skillEntries: EnabledDevicePackageResources['skillEntries'] = [];
|
||||
const extensionPaths: string[] = [];
|
||||
for (const record of enabled) {
|
||||
const packageRoot = this.packageDirectory(record);
|
||||
await this.assertRecordEntries(packageRoot, record);
|
||||
for (const entry of record.skillEntries) skillEntries.push({ ...entry, packageRoot });
|
||||
for (const entry of record.extensionEntries) {
|
||||
extensionPaths.push(path.join(packageRoot, ...entry.split('/')));
|
||||
}
|
||||
}
|
||||
return {
|
||||
generation: index.generation,
|
||||
packageIds: enabled.map(({ packageId }) => packageId),
|
||||
packageRefs: enabled.map(({ packageId, resolvedVersion }) => ({ packageId, resolvedVersion })),
|
||||
skillEntries,
|
||||
extensionPaths,
|
||||
};
|
||||
}
|
||||
|
||||
registerActiveWorker(refs: readonly { packageId: string; resolvedVersion: string }[]): () => Promise<void> {
|
||||
const keys = [...new Set(refs.map((record) => this.packageKey(record)))];
|
||||
for (const key of keys) this.activePackages.set(key, (this.activePackages.get(key) ?? 0) + 1);
|
||||
let released = false;
|
||||
return async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
for (const key of keys) {
|
||||
const count = this.activePackages.get(key) ?? 0;
|
||||
if (count <= 1) this.activePackages.delete(key);
|
||||
else this.activePackages.set(key, count - 1);
|
||||
}
|
||||
for (const target of [...this.pendingCleanup]) {
|
||||
const [key, packagePath] = target.split('\u0000', 2);
|
||||
if (!key || !packagePath || (this.activePackages.get(key) ?? 0) > 0) continue;
|
||||
this.pendingCleanup.delete(target);
|
||||
await rm(packagePath, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private async readPlan(planId: string): Promise<StagedPlanV1> {
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/u.test(planId)) {
|
||||
throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable');
|
||||
}
|
||||
try {
|
||||
const value = JSON.parse(await readFile(path.join(this.stagingDir, planId, 'plan.json'), 'utf8')) as unknown;
|
||||
if (!isRecord(value) || value.schemaVersion !== 1 || !isRecord(value.preview)) throw new Error();
|
||||
const record = parseRecord(value.record);
|
||||
const preview = value.preview as unknown as InstallPreviewV1;
|
||||
if (preview.schemaVersion !== 1 || preview.planId !== planId
|
||||
|| !Array.isArray(preview.warnings) || !Array.isArray(preview.skillEntries)
|
||||
|| !Array.isArray(preview.extensionEntries)) throw new Error();
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
requestedByTurnId: string(value.requestedByTurnId, 'Request turn id', 256),
|
||||
preview,
|
||||
record,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DevicePackageError) throw error;
|
||||
throw new DevicePackageError('local_package_plan_expired', 'Install preview is unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private async readIndex(): Promise<DevicePackageIndexV1> {
|
||||
try {
|
||||
return parseIndex(JSON.parse(await readFile(this.indexPath, 'utf8')) as unknown);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return { schemaVersion: 1, generation: 0, packages: [] };
|
||||
}
|
||||
if (error instanceof DevicePackageError) throw error;
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package index is invalid');
|
||||
}
|
||||
}
|
||||
|
||||
private packageDirectory(record: Pick<DevicePackageRecordV1, 'packageId' | 'resolvedVersion'>): string {
|
||||
return path.join(this.packagesDir, record.packageId, safeStorageSegment(record.resolvedVersion));
|
||||
}
|
||||
|
||||
private packageKey(record: Pick<DevicePackageRecordV1, 'packageId' | 'resolvedVersion'>): string {
|
||||
return `${record.packageId}@${record.resolvedVersion}`;
|
||||
}
|
||||
|
||||
private async assertRecordEntries(packageRoot: string, record: DevicePackageRecordV1): Promise<void> {
|
||||
if (await pathMetadata(packageRoot) !== 'directory') {
|
||||
throw new DevicePackageError('local_package_install_failed', 'Device package bytes are unavailable');
|
||||
}
|
||||
for (const entry of [...record.skillEntries.map(({ entryPath }) => entryPath), ...record.extensionEntries]) {
|
||||
const candidate = path.resolve(packageRoot, ...entry.split('/'));
|
||||
try {
|
||||
relativeContainedPath(packageRoot, candidate);
|
||||
} catch {
|
||||
throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry escapes its root');
|
||||
}
|
||||
if (await pathMetadata(candidate) !== 'file') {
|
||||
throw new DevicePackageError('local_package_manifest_invalid', 'Device package entry is unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupPackage(record: DevicePackageRecordV1): Promise<void> {
|
||||
const packagePath = this.packageDirectory(record);
|
||||
const key = this.packageKey(record);
|
||||
if ((this.activePackages.get(key) ?? 0) > 0) {
|
||||
this.pendingCleanup.add(`${key}\u0000${packagePath}`);
|
||||
return;
|
||||
}
|
||||
await rm(packagePath, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
|
||||
private withOperation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = this.operation.then(operation, operation);
|
||||
this.operation = result.then(() => undefined, () => undefined);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user