@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Changed `pi update` to batch npm package updates per scope and run git package updates with bounded parallelism, reducing multi-package update time while preserving skip behavior for pinned and already-current packages ([#2980](https://github.com/badlogic/pi-mono/issues/2980))
|
||||||
- Documented async extension factory functions in the extensions and custom-provider docs, including startup ordering and dynamic model/provider discovery via async initialization ([#3469](https://github.com/badlogic/pi-mono/issues/3469))
|
- Documented async extension factory functions in the extensions and custom-provider docs, including startup ordering and dynamic model/provider discovery via async initialization ([#3469](https://github.com/badlogic/pi-mono/issues/3469))
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import type { PackageSource, SettingsManager } from "./settings-manager.js";
|
|||||||
|
|
||||||
const NETWORK_TIMEOUT_MS = 10000;
|
const NETWORK_TIMEOUT_MS = 10000;
|
||||||
const UPDATE_CHECK_CONCURRENCY = 4;
|
const UPDATE_CHECK_CONCURRENCY = 4;
|
||||||
|
const GIT_UPDATE_CONCURRENCY = 4;
|
||||||
|
|
||||||
function isOfflineModeEnabled(): boolean {
|
function isOfflineModeEnabled(): boolean {
|
||||||
const value = process.env.PI_OFFLINE;
|
const value = process.env.PI_OFFLINE;
|
||||||
@@ -116,6 +117,21 @@ type LocalSource = {
|
|||||||
|
|
||||||
type ParsedSource = NpmSource | GitSource | LocalSource;
|
type ParsedSource = NpmSource | GitSource | LocalSource;
|
||||||
|
|
||||||
|
type InstalledSourceScope = Exclude<SourceScope, "temporary">;
|
||||||
|
|
||||||
|
interface ConfiguredUpdateSource {
|
||||||
|
source: string;
|
||||||
|
scope: InstalledSourceScope;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NpmUpdateTarget extends ConfiguredUpdateSource {
|
||||||
|
parsed: NpmSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitUpdateTarget extends ConfiguredUpdateSource {
|
||||||
|
parsed: GitSource;
|
||||||
|
}
|
||||||
|
|
||||||
interface PiManifest {
|
interface PiManifest {
|
||||||
extensions?: string[];
|
extensions?: string[];
|
||||||
skills?: string[];
|
skills?: string[];
|
||||||
@@ -970,18 +986,19 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
const projectSettings = this.settingsManager.getProjectSettings();
|
const projectSettings = this.settingsManager.getProjectSettings();
|
||||||
const identity = source ? this.getPackageIdentity(source) : undefined;
|
const identity = source ? this.getPackageIdentity(source) : undefined;
|
||||||
let matched = false;
|
let matched = false;
|
||||||
|
const updateSources: ConfiguredUpdateSource[] = [];
|
||||||
|
|
||||||
for (const pkg of globalSettings.packages ?? []) {
|
for (const pkg of globalSettings.packages ?? []) {
|
||||||
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
|
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
|
||||||
if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue;
|
if (identity && this.getPackageIdentity(sourceStr, "user") !== identity) continue;
|
||||||
matched = true;
|
matched = true;
|
||||||
await this.updateSourceForScope(sourceStr, "user");
|
updateSources.push({ source: sourceStr, scope: "user" });
|
||||||
}
|
}
|
||||||
for (const pkg of projectSettings.packages ?? []) {
|
for (const pkg of projectSettings.packages ?? []) {
|
||||||
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
|
const sourceStr = typeof pkg === "string" ? pkg : pkg.source;
|
||||||
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;
|
if (identity && this.getPackageIdentity(sourceStr, "project") !== identity) continue;
|
||||||
matched = true;
|
matched = true;
|
||||||
await this.updateSourceForScope(sourceStr, "project");
|
updateSources.push({ source: sourceStr, scope: "project" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source && !matched) {
|
if (source && !matched) {
|
||||||
@@ -992,48 +1009,106 @@ export class DefaultPackageManager implements PackageManager {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.updateConfiguredSources(updateSources);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateSourceForScope(source: string, scope: SourceScope): Promise<void> {
|
private async updateConfiguredSources(sources: ConfiguredUpdateSource[]): Promise<void> {
|
||||||
if (isOfflineModeEnabled()) {
|
if (isOfflineModeEnabled() || sources.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const parsed = this.parseSource(source);
|
|
||||||
if (parsed.type === "npm") {
|
|
||||||
if (parsed.pinned) return;
|
|
||||||
|
|
||||||
const installedPath = this.getNpmInstallPath(parsed, scope);
|
const npmCandidates: NpmUpdateTarget[] = [];
|
||||||
const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;
|
const gitCandidates: GitUpdateTarget[] = [];
|
||||||
if (installedVersion) {
|
|
||||||
try {
|
for (const entry of sources) {
|
||||||
const latestVersion = await this.getLatestNpmVersion(parsed.name);
|
const parsed = this.parseSource(entry.source);
|
||||||
if (latestVersion === installedVersion) {
|
if (parsed.type === "local" || parsed.pinned) {
|
||||||
return;
|
continue;
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Preserve existing update behavior when version lookup fails.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
if (parsed.type === "npm") {
|
||||||
|
npmCandidates.push({ ...entry, parsed });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
gitCandidates.push({ ...entry, parsed });
|
||||||
|
}
|
||||||
|
|
||||||
await this.withProgress("update", source, `Updating ${source}...`, async () => {
|
const npmCheckTasks = npmCandidates.map((entry) => async () => ({
|
||||||
await this.installNpm(
|
entry,
|
||||||
{
|
shouldUpdate: await this.shouldUpdateNpmSource(entry.parsed, entry.scope),
|
||||||
...parsed,
|
}));
|
||||||
spec: `${parsed.name}@latest`,
|
const npmCheckResults = await this.runWithConcurrency(npmCheckTasks, UPDATE_CHECK_CONCURRENCY);
|
||||||
},
|
const userNpmUpdates: NpmUpdateTarget[] = [];
|
||||||
scope,
|
const projectNpmUpdates: NpmUpdateTarget[] = [];
|
||||||
false,
|
for (const result of npmCheckResults) {
|
||||||
);
|
if (!result.shouldUpdate) {
|
||||||
});
|
continue;
|
||||||
|
}
|
||||||
|
if (result.entry.scope === "user") {
|
||||||
|
userNpmUpdates.push(result.entry);
|
||||||
|
} else {
|
||||||
|
projectNpmUpdates.push(result.entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tasks: Promise<void>[] = [];
|
||||||
|
if (userNpmUpdates.length > 0) {
|
||||||
|
tasks.push(this.updateNpmBatch(userNpmUpdates, "user"));
|
||||||
|
}
|
||||||
|
if (projectNpmUpdates.length > 0) {
|
||||||
|
tasks.push(this.updateNpmBatch(projectNpmUpdates, "project"));
|
||||||
|
}
|
||||||
|
if (gitCandidates.length > 0) {
|
||||||
|
const gitTasks = gitCandidates.map(
|
||||||
|
(entry) => async () =>
|
||||||
|
this.withProgress("update", entry.source, `Updating ${entry.source}...`, async () => {
|
||||||
|
await this.updateGit(entry.parsed, entry.scope);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
tasks.push(this.runWithConcurrency(gitTasks, GIT_UPDATE_CONCURRENCY).then(() => {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(tasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async shouldUpdateNpmSource(source: NpmSource, scope: InstalledSourceScope): Promise<boolean> {
|
||||||
|
const installedPath = this.getNpmInstallPath(source, scope);
|
||||||
|
const installedVersion = existsSync(installedPath) ? this.getInstalledNpmVersion(installedPath) : undefined;
|
||||||
|
if (!installedVersion) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const latestVersion = await this.getLatestNpmVersion(source.name);
|
||||||
|
return latestVersion !== installedVersion;
|
||||||
|
} catch {
|
||||||
|
// Preserve existing update behavior when version lookup fails.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async updateNpmBatch(sources: NpmUpdateTarget[], scope: InstalledSourceScope): Promise<void> {
|
||||||
|
if (sources.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (parsed.type === "git") {
|
|
||||||
if (parsed.pinned) return;
|
const sourceLabel = sources.length === 1 ? sources[0].source : `${scope} npm packages`;
|
||||||
await this.withProgress("update", source, `Updating ${source}...`, async () => {
|
const message = sources.length === 1 ? `Updating ${sources[0].source}...` : `Updating ${scope} npm packages...`;
|
||||||
await this.updateGit(parsed, scope);
|
const specs = sources.map((entry) => `${entry.parsed.name}@latest`);
|
||||||
});
|
|
||||||
|
await this.withProgress("update", sourceLabel, message, async () => {
|
||||||
|
await this.installNpmBatch(specs, scope);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async installNpmBatch(specs: string[], scope: InstalledSourceScope): Promise<void> {
|
||||||
|
if (scope === "user") {
|
||||||
|
await this.runNpmCommand(["install", "-g", ...specs]);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const installRoot = this.getNpmInstallRoot(scope, false);
|
||||||
|
this.ensureNpmProject(installRoot);
|
||||||
|
await this.runNpmCommand(["install", ...specs, "--prefix", installRoot]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async checkForAvailableUpdates(): Promise<PackageUpdate[]> {
|
async checkForAvailableUpdates(): Promise<PackageUpdate[]> {
|
||||||
|
|||||||
@@ -1497,6 +1497,115 @@ export default function(api) { api.registerTool({ name: "test", description: "te
|
|||||||
expect(runCommandSpy).not.toHaveBeenCalled();
|
expect(runCommandSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should batch npm updates per scope and run git updates in parallel while skipping pinned and current packages", async () => {
|
||||||
|
vi.spyOn(packageManager as any, "getGlobalNpmRoot").mockReturnValue(join(agentDir, "node_modules"));
|
||||||
|
|
||||||
|
const userOldPath = join(agentDir, "node_modules", "user-old");
|
||||||
|
const userCurrentPath = join(agentDir, "node_modules", "user-current");
|
||||||
|
const userUnknownPath = join(agentDir, "node_modules", "user-unknown");
|
||||||
|
const projectOldPath = join(tempDir, ".pi", "npm", "node_modules", "project-old");
|
||||||
|
const projectCurrentPath = join(tempDir, ".pi", "npm", "node_modules", "project-current");
|
||||||
|
const installPaths = [userOldPath, userCurrentPath, userUnknownPath, projectOldPath, projectCurrentPath];
|
||||||
|
for (const installPath of installPaths) {
|
||||||
|
mkdirSync(installPath, { recursive: true });
|
||||||
|
}
|
||||||
|
writeFileSync(join(userOldPath, "package.json"), JSON.stringify({ name: "user-old", version: "1.0.0" }));
|
||||||
|
writeFileSync(
|
||||||
|
join(userCurrentPath, "package.json"),
|
||||||
|
JSON.stringify({ name: "user-current", version: "1.0.0" }),
|
||||||
|
);
|
||||||
|
writeFileSync(
|
||||||
|
join(userUnknownPath, "package.json"),
|
||||||
|
JSON.stringify({ name: "user-unknown", version: "1.0.0" }),
|
||||||
|
);
|
||||||
|
writeFileSync(join(projectOldPath, "package.json"), JSON.stringify({ name: "project-old", version: "1.0.0" }));
|
||||||
|
writeFileSync(
|
||||||
|
join(projectCurrentPath, "package.json"),
|
||||||
|
JSON.stringify({ name: "project-current", version: "1.0.0" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
settingsManager.setPackages([
|
||||||
|
"npm:user-old",
|
||||||
|
"npm:user-current",
|
||||||
|
"npm:user-unknown",
|
||||||
|
"npm:user-pinned@1.0.0",
|
||||||
|
"git:github.com/example/user-repo-a",
|
||||||
|
"git:github.com/example/user-repo-b",
|
||||||
|
"git:github.com/example/user-repo-pinned@v1",
|
||||||
|
]);
|
||||||
|
settingsManager.setProjectPackages([
|
||||||
|
"npm:project-old",
|
||||||
|
"npm:project-current",
|
||||||
|
"npm:project-missing",
|
||||||
|
"git:github.com/example/project-repo-a",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runCommandCaptureSpy = vi
|
||||||
|
.spyOn(packageManager as any, "runCommandCapture")
|
||||||
|
.mockImplementation(async (...callArgs: unknown[]) => {
|
||||||
|
const [_command, args] = callArgs as [string, string[]];
|
||||||
|
if (args[0] !== "view") {
|
||||||
|
throw new Error(`Unexpected runCommandCapture args: ${args.join(" ")}`);
|
||||||
|
}
|
||||||
|
switch (args[1]) {
|
||||||
|
case "user-old":
|
||||||
|
case "project-old":
|
||||||
|
return '"2.0.0"';
|
||||||
|
case "user-current":
|
||||||
|
case "project-current":
|
||||||
|
return '"1.0.0"';
|
||||||
|
case "user-unknown":
|
||||||
|
throw new Error("registry unavailable");
|
||||||
|
default:
|
||||||
|
throw new Error(`Unexpected package lookup: ${args[1]}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeNpmUpdates = 0;
|
||||||
|
let maxConcurrentNpmUpdates = 0;
|
||||||
|
const runCommandSpy = vi
|
||||||
|
.spyOn(packageManager as any, "runCommand")
|
||||||
|
.mockImplementation(async (...callArgs: unknown[]) => {
|
||||||
|
const [command, args] = callArgs as [string, string[]];
|
||||||
|
if (command !== "npm") {
|
||||||
|
throw new Error(`Unexpected runCommand call: ${command} ${args.join(" ")}`);
|
||||||
|
}
|
||||||
|
activeNpmUpdates += 1;
|
||||||
|
maxConcurrentNpmUpdates = Math.max(maxConcurrentNpmUpdates, activeNpmUpdates);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
activeNpmUpdates -= 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
let activeGitUpdates = 0;
|
||||||
|
let maxConcurrentGitUpdates = 0;
|
||||||
|
const updateGitSpy = vi.spyOn(packageManager as any, "updateGit").mockImplementation(async () => {
|
||||||
|
activeGitUpdates += 1;
|
||||||
|
maxConcurrentGitUpdates = Math.max(maxConcurrentGitUpdates, activeGitUpdates);
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
activeGitUpdates -= 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
await packageManager.update();
|
||||||
|
|
||||||
|
expect(runCommandCaptureSpy).toHaveBeenCalledTimes(5);
|
||||||
|
expect(runCommandSpy).toHaveBeenCalledTimes(2);
|
||||||
|
expect(runCommandSpy).toHaveBeenNthCalledWith(
|
||||||
|
1,
|
||||||
|
"npm",
|
||||||
|
["install", "-g", "user-old@latest", "user-unknown@latest"],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(runCommandSpy).toHaveBeenNthCalledWith(
|
||||||
|
2,
|
||||||
|
"npm",
|
||||||
|
["install", "project-old@latest", "project-missing@latest", "--prefix", join(tempDir, ".pi", "npm")],
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
expect(updateGitSpy).toHaveBeenCalledTimes(3);
|
||||||
|
expect(maxConcurrentNpmUpdates).toBeGreaterThan(1);
|
||||||
|
expect(maxConcurrentGitUpdates).toBeGreaterThan(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("should suggest npm source prefixes for update lookups", async () => {
|
it("should suggest npm source prefixes for update lookups", async () => {
|
||||||
settingsManager.setProjectPackages(["npm:example"]);
|
settingsManager.setProjectPackages(["npm:example"]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user