Archived
Merge remote-tracking branch 'origin/main' into review/pr-5-machine-federation-fixes
# Conflicts: # src/client/src/api/clients.ts # src/client/src/components/PiWebApp.ts # src/client/src/components/PromptEditor.ts # src/server/app.ts # src/server/terminalProxyRoutes.ts # src/server/workspaces/fileSuggestions.ts
This commit is contained in:
+5
-4
@@ -9,13 +9,13 @@ import { ProjectService } from "./projects/projectService.js";
|
||||
import { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
import { listFileSuggestions, listPathSuggestions } from "./workspaces/fileSuggestions.js";
|
||||
import { listDirectorySuggestions } from "./projects/directorySuggestions.js";
|
||||
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import { registerSessionProxyRoutes, type SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { registerWorkspaceExplorerRoutes } from "./workspaceExplorerRoutes.js";
|
||||
import { registerGitRoutes } from "./gitRoutes.js";
|
||||
import { registerTerminalProxyRoutes } from "./terminalProxyRoutes.js";
|
||||
import { PiWebPluginService } from "./piWebPluginService.js";
|
||||
import { getPiWebStatus } from "./piWebStatus.js";
|
||||
import { getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { MachineService } from "./machines/machineService.js";
|
||||
import { registerMachineRoutes } from "./machines/machineRoutes.js";
|
||||
import { registerMachineProxyRoutes } from "./machines/machineProxyRoutes.js";
|
||||
@@ -69,11 +69,11 @@ function registerLocalProjectRoutes(app: FastifyInstance, projects: ProjectServi
|
||||
}
|
||||
|
||||
function registerLocalFileSuggestionRoutes(app: FastifyInstance, prefix: string): void {
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path" } }>(`${prefix}/files`, async (request, reply) => {
|
||||
app.get<{ Querystring: { cwd?: string; q?: string; kind?: "tracked" | "untracked" | "other"; mode?: "file" | "path"; scope?: "tracked" | "all" } }>(`${prefix}/files`, async (request, reply) => {
|
||||
if (request.query.cwd === undefined || request.query.cwd === "") return reply.code(400).send({ error: "cwd query parameter is required" });
|
||||
try {
|
||||
if (request.query.mode === "path") return await listPathSuggestions(request.query.cwd, request.query.q ?? "");
|
||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", request.query.kind);
|
||||
return await listFileSuggestions(request.query.cwd, request.query.q ?? "", { kind: request.query.kind, scope: request.query.scope });
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
@@ -99,6 +99,7 @@ export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInsta
|
||||
});
|
||||
|
||||
app.get("/api/pi-web/status", async () => getPiWebStatus());
|
||||
app.get("/api/pi-web/version", async () => getPiWebVersionStatus());
|
||||
|
||||
registerMachineRoutes(app, machines);
|
||||
|
||||
|
||||
+110
-20
@@ -1,12 +1,17 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { comparePackageVersions, getPiWebStatus } from "./piWebStatus.js";
|
||||
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||
import { comparePackageVersions, getPiWebStatus, getPiWebVersionStatus } from "./piWebStatus.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import type { PiWebComponentStatus } from "../shared/apiTypes.js";
|
||||
|
||||
const originalSkipVersionCheck = process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
const originalHome = process.env["HOME"];
|
||||
|
||||
afterEach(() => {
|
||||
if (originalSkipVersionCheck === undefined) delete process.env["PI_WEB_SKIP_VERSION_CHECK"];
|
||||
else process.env["PI_WEB_SKIP_VERSION_CHECK"] = originalSkipVersionCheck;
|
||||
restoreEnv("PI_WEB_SKIP_VERSION_CHECK", originalSkipVersionCheck);
|
||||
restoreEnv("HOME", originalHome);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -17,23 +22,34 @@ describe("PI WEB status", () => {
|
||||
expect(comparePackageVersions("1.202605.7", "1.202605.8")).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it("returns installed and running version components without release metadata", async () => {
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202605.7",
|
||||
installedVersion: "1.202605.8",
|
||||
stale: true,
|
||||
available: true,
|
||||
});
|
||||
|
||||
const status = await getPiWebVersionStatus(daemon);
|
||||
|
||||
expect(status.packageName).toBe("@jmfederico/pi-web");
|
||||
expect(status.components.web.component).toBe("web");
|
||||
expect(status.components.sessiond.runtimeVersion).toBe("1.202605.7");
|
||||
expect(status).not.toHaveProperty("release");
|
||||
});
|
||||
|
||||
it("reports stale session daemon versions as messages", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
const daemon = new SessionDaemonClient();
|
||||
vi.spyOn(daemon, "request").mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
version: {
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202605.7",
|
||||
installedVersion: "1.202605.8",
|
||||
stale: true,
|
||||
available: true,
|
||||
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||
},
|
||||
}),
|
||||
const daemon = daemonWithComponent({
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202605.7",
|
||||
installedVersion: "1.202605.8",
|
||||
stale: true,
|
||||
available: true,
|
||||
installation: { kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user", path: "/tmp/pi-web" },
|
||||
});
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
@@ -41,7 +57,81 @@ describe("PI WEB status", () => {
|
||||
expect(status.release.skipped).toBe(true);
|
||||
expect(status.components.sessiond.stale).toBe(true);
|
||||
expect(status.components.sessiond.installation).toMatchObject({ kind: "pi-package", source: "npm:@jmfederico/pi-web", scope: "user" });
|
||||
expect(status.commands.update).not.toBe("");
|
||||
expect(status.messages.map((message) => message.id)).toContain("sessiond-stale");
|
||||
});
|
||||
|
||||
it("suggests native systemd commands for local development services", async () => {
|
||||
if (process.platform !== "linux") return;
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
const home = await tempHome();
|
||||
try {
|
||||
process.env["HOME"] = home;
|
||||
await installSystemdServiceFiles(home, ["pi-web-sessiond.service", "pi-web-ui-dev.service"]);
|
||||
const daemon = daemonWithComponent(staleLocalSessiond());
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
|
||||
expect(status.commands.restart).toBe("systemctl --user restart pi-web-sessiond.service pi-web-ui-dev.service");
|
||||
expect(status.commands.restartWeb).toBe("systemctl --user restart pi-web-ui-dev.service");
|
||||
expect(status.commands.restartSessiond).toBe("systemctl --user restart pi-web-sessiond.service");
|
||||
expect(status.messages.find((message) => message.id === "sessiond-stale")?.command).toBe("systemctl --user restart pi-web-sessiond.service");
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("omits local restart commands when no native service command is known", async () => {
|
||||
process.env["PI_WEB_SKIP_VERSION_CHECK"] = "1";
|
||||
const home = await tempHome();
|
||||
try {
|
||||
process.env["HOME"] = home;
|
||||
const daemon = daemonWithComponent(staleLocalSessiond());
|
||||
|
||||
const status = await getPiWebStatus(daemon);
|
||||
const staleMessage = status.messages.find((message) => message.id === "sessiond-stale");
|
||||
|
||||
expect(status.commands.restart).toBeUndefined();
|
||||
expect(staleMessage?.command).toBeUndefined();
|
||||
expect(JSON.stringify(status)).not.toContain("pi-web restart");
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
function daemonWithComponent(component: PiWebComponentStatus): SessionDaemonClient {
|
||||
const daemon = new SessionDaemonClient();
|
||||
vi.spyOn(daemon, "request").mockResolvedValue({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ version: component }),
|
||||
});
|
||||
return daemon;
|
||||
}
|
||||
|
||||
function staleLocalSessiond(): PiWebComponentStatus {
|
||||
return {
|
||||
component: "sessiond",
|
||||
label: "Session daemon",
|
||||
runtimeVersion: "1.202605.7",
|
||||
installedVersion: "1.202605.8",
|
||||
stale: true,
|
||||
available: true,
|
||||
installation: { kind: "local", path: "/srv/dev/pi-web" },
|
||||
};
|
||||
}
|
||||
|
||||
async function tempHome(): Promise<string> {
|
||||
return await mkdtemp(join(tmpdir(), "pi-web-status-"));
|
||||
}
|
||||
|
||||
async function installSystemdServiceFiles(home: string, names: string[]): Promise<void> {
|
||||
const dir = join(home, ".config", "systemd", "user");
|
||||
await mkdir(dir, { recursive: true });
|
||||
await Promise.all(names.map((name) => writeFile(join(dir, name), "")));
|
||||
}
|
||||
|
||||
function restoreEnv(key: string, value: string | undefined): void {
|
||||
if (value === undefined) Reflect.deleteProperty(process.env, key);
|
||||
else process.env[key] = value;
|
||||
}
|
||||
|
||||
+190
-73
@@ -1,11 +1,13 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { readFile, realpath, stat } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DefaultPackageManager, getAgentDir, SettingsManager } from "@earendil-works/pi-coding-agent";
|
||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse } from "../shared/apiTypes.js";
|
||||
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||
import type { PiWebComponentStatus, PiWebInstallationInfo, PiWebReleaseStatus, PiWebServiceComponent, PiWebStatusMessage, PiWebStatusResponse, PiWebVersionResponse } from "../shared/apiTypes.js";
|
||||
import { parsePiWebComponentStatus } from "../shared/piWebStatusParsing.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
|
||||
const PI_WEB_PACKAGE_NAME = "@jmfederico/pi-web";
|
||||
const PI_WEB_NPM_SOURCE = `npm:${PI_WEB_PACKAGE_NAME}`;
|
||||
@@ -13,12 +15,46 @@ const DEFAULT_VERSION = "0.0.0-dev";
|
||||
const LATEST_RELEASE_CACHE_MS = 6 * 60 * 60 * 1000;
|
||||
const VERSION_CHECK_TIMEOUT_MS = 5000;
|
||||
|
||||
const restartCommands = {
|
||||
restart: "pi-web restart",
|
||||
restartSystemd: "pi-web restart",
|
||||
restartDev: "pi-web restart",
|
||||
type ServiceId = "sessiond" | "web" | "uiDev";
|
||||
type NativeServiceBackendKind = "systemd" | "launchd";
|
||||
|
||||
interface NativeServiceRef {
|
||||
id: ServiceId;
|
||||
systemdName: string;
|
||||
launchdLabel: string;
|
||||
launchdPlistName: string;
|
||||
}
|
||||
|
||||
interface NativeServiceCommands {
|
||||
restart?: string;
|
||||
restartWeb?: string;
|
||||
restartSessiond?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
const serviceRefs: Record<ServiceId, NativeServiceRef> = {
|
||||
sessiond: {
|
||||
id: "sessiond",
|
||||
systemdName: "pi-web-sessiond.service",
|
||||
launchdLabel: "com.pi-web.sessiond",
|
||||
launchdPlistName: "com.pi-web.sessiond.plist",
|
||||
},
|
||||
web: {
|
||||
id: "web",
|
||||
systemdName: "pi-web.service",
|
||||
launchdLabel: "com.pi-web.web",
|
||||
launchdPlistName: "com.pi-web.web.plist",
|
||||
},
|
||||
uiDev: {
|
||||
id: "uiDev",
|
||||
systemdName: "pi-web-ui-dev.service",
|
||||
launchdLabel: "com.pi-web.ui-dev",
|
||||
launchdPlistName: "com.pi-web.ui-dev.plist",
|
||||
},
|
||||
};
|
||||
|
||||
const startServiceOrder: ServiceId[] = ["sessiond", "web", "uiDev"];
|
||||
|
||||
interface PackageInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
@@ -47,20 +83,27 @@ export async function getPiWebComponentStatus(component: PiWebServiceComponent):
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
const web = await getPiWebComponentStatus("web");
|
||||
const [installed, sessiond] = await Promise.all([
|
||||
readInstalledPackageInfo(),
|
||||
export async function getPiWebVersionStatus(daemon = new SessionDaemonClient()): Promise<PiWebVersionResponse> {
|
||||
const [web, sessiond] = await Promise.all([
|
||||
getPiWebComponentStatus("web"),
|
||||
getSessiondComponentStatus(daemon),
|
||||
]);
|
||||
const release = await getLatestReleaseStatus(installed?.version ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const components = { web, sessiond };
|
||||
const commands = commandsFor(web.installation ?? sessiond.installation);
|
||||
const messages = buildMessages(components, release, commands);
|
||||
return {
|
||||
packageName: PI_WEB_PACKAGE_NAME,
|
||||
generatedAt: new Date().toISOString(),
|
||||
components,
|
||||
components: { web, sessiond },
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPiWebStatus(daemon = new SessionDaemonClient()): Promise<PiWebStatusResponse> {
|
||||
const versionStatus = await getPiWebVersionStatus(daemon);
|
||||
const { web, sessiond } = versionStatus.components;
|
||||
const release = await getLatestReleaseStatus(web.installedVersion ?? web.runtimeVersion ?? DEFAULT_VERSION);
|
||||
const components = { web, sessiond };
|
||||
const commands = commandsFor(components);
|
||||
const messages = buildMessages(components, release, commands);
|
||||
return {
|
||||
...versionStatus,
|
||||
release,
|
||||
commands,
|
||||
messages,
|
||||
@@ -179,54 +222,13 @@ async function getSessiondComponentStatus(daemon: SessionDaemonClient): Promise<
|
||||
}
|
||||
const parsed: unknown = upstream.body === "" ? undefined : JSON.parse(upstream.body);
|
||||
const version = isRecord(parsed) ? parsed["version"] : undefined;
|
||||
const component = parseComponentStatus(version);
|
||||
const component = parsePiWebComponentStatus(version);
|
||||
return component ?? unavailableSessiond("health response did not include version information");
|
||||
} catch (error) {
|
||||
return unavailableSessiond(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
function parseComponentStatus(value: unknown): PiWebComponentStatus | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const component = value["component"];
|
||||
const label = value["label"];
|
||||
const runtimeVersion = value["runtimeVersion"];
|
||||
const installedVersion = value["installedVersion"];
|
||||
const stale = value["stale"];
|
||||
const available = value["available"];
|
||||
const error = value["error"];
|
||||
const installation = parseInstallationInfo(value["installation"]);
|
||||
if (component !== "web" && component !== "sessiond") return undefined;
|
||||
if (typeof label !== "string" || typeof stale !== "boolean" || typeof available !== "boolean") return undefined;
|
||||
return {
|
||||
component,
|
||||
label,
|
||||
...(typeof runtimeVersion === "string" ? { runtimeVersion } : {}),
|
||||
...(typeof installedVersion === "string" ? { installedVersion } : {}),
|
||||
stale,
|
||||
available,
|
||||
...(installation === undefined ? {} : { installation }),
|
||||
...(typeof error === "string" ? { error } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseInstallationInfo(value: unknown): PiWebInstallationInfo | undefined {
|
||||
if (!isRecord(value)) return undefined;
|
||||
const kind = value["kind"];
|
||||
const path = value["path"];
|
||||
const source = value["source"];
|
||||
const scope = value["scope"];
|
||||
const npmRoot = value["npmRoot"];
|
||||
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") return undefined;
|
||||
return {
|
||||
kind,
|
||||
...(typeof path === "string" ? { path } : {}),
|
||||
...(typeof source === "string" ? { source } : {}),
|
||||
...(scope === "user" || scope === "project" ? { scope } : {}),
|
||||
...(typeof npmRoot === "string" ? { npmRoot } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSessiond(error: string): PiWebComponentStatus {
|
||||
return {
|
||||
component: "sessiond",
|
||||
@@ -280,21 +282,122 @@ async function fetchLatestNpmVersion(currentVersion: string): Promise<string> {
|
||||
return version;
|
||||
}
|
||||
|
||||
function commandsFor(installation: PiWebInstallationInfo | undefined): PiWebStatusResponse["commands"] {
|
||||
function commandsFor(components: PiWebStatusResponse["components"]): PiWebStatusResponse["commands"] {
|
||||
const installation = preferredInstallation(components);
|
||||
const serviceCommands = nativeServiceCommands();
|
||||
const cliCommands = piWebCliCommands(installation);
|
||||
const restart = restartCommandFor(installation, serviceCommands, cliCommands);
|
||||
const restartWeb = serviceCommands.restartWeb ?? cliCommands.restart;
|
||||
const restartSessiond = serviceCommands.restartSessiond ?? cliCommands.restart;
|
||||
const status = serviceCommands.status ?? cliCommands.status;
|
||||
const update = updateCommandFor(installation, restart);
|
||||
|
||||
return {
|
||||
update: updateCommandFor(installation),
|
||||
...restartCommands,
|
||||
...(update === undefined ? {} : { update }),
|
||||
...(restart === undefined ? {} : { restart }),
|
||||
...(restartWeb === undefined ? {} : { restartWeb }),
|
||||
...(restartSessiond === undefined ? {} : { restartSessiond }),
|
||||
...(status === undefined ? {} : { status }),
|
||||
};
|
||||
}
|
||||
|
||||
function updateCommandFor(installation: PiWebInstallationInfo | undefined): string {
|
||||
function preferredInstallation(components: PiWebStatusResponse["components"]): PiWebInstallationInfo | undefined {
|
||||
const web = components.web.installation;
|
||||
const sessiond = components.sessiond.installation;
|
||||
if (web?.kind === "local" || sessiond?.kind === "local") return web?.kind === "local" ? web : sessiond;
|
||||
return web ?? sessiond;
|
||||
}
|
||||
|
||||
function piWebCliCommands(installation: PiWebInstallationInfo | undefined): NativeServiceCommands {
|
||||
if (installation?.kind !== "npm-global" || !hasCommand("pi-web")) return {};
|
||||
return { restart: "pi-web restart", status: "pi-web status" };
|
||||
}
|
||||
|
||||
function restartCommandFor(installation: PiWebInstallationInfo | undefined, serviceCommands: NativeServiceCommands, cliCommands: NativeServiceCommands): string | undefined {
|
||||
if (installation?.kind === "local" || installation?.kind === "pi-package") return serviceCommands.restart ?? cliCommands.restart;
|
||||
return cliCommands.restart ?? serviceCommands.restart;
|
||||
}
|
||||
|
||||
function updateCommandFor(installation: PiWebInstallationInfo | undefined, restartCommand: string | undefined): string | undefined {
|
||||
if (restartCommand === undefined) return undefined;
|
||||
if (installation?.kind === "pi-package") {
|
||||
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommands.restart}`;
|
||||
if (!hasCommand("pi")) return undefined;
|
||||
return `pi update ${installation.source ?? PI_WEB_NPM_SOURCE} && ${restartCommand}`;
|
||||
}
|
||||
if (installation?.kind === "local" && installation.path !== undefined) {
|
||||
return `cd ${shellQuote(installation.path)} && git pull && npm install && npm run build && ${restartCommands.restart}`;
|
||||
if (!hasCommand("npm") || !isGitCheckoutWithUpstream(installation.path)) return undefined;
|
||||
return `cd ${shellQuote(installation.path)} && git pull --ff-only && npm install && npm run build && ${restartCommand}`;
|
||||
}
|
||||
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommands.restart}`;
|
||||
if (installation?.kind !== "npm-global" || !hasCommand("npm")) return undefined;
|
||||
return `npm install -g ${PI_WEB_PACKAGE_NAME} && ${restartCommand}`;
|
||||
}
|
||||
|
||||
function nativeServiceCommands(): NativeServiceCommands {
|
||||
const backend = nativeServiceBackend();
|
||||
if (backend === undefined) return {};
|
||||
const installed = installedServiceIds(backend);
|
||||
if (installed.size === 0) return {};
|
||||
const web = installedServiceRefs(installed, ["web", "uiDev"]);
|
||||
const sessiond = installedServiceRefs(installed, ["sessiond"]);
|
||||
const restartable = web.length === 0 ? [] : installedServiceRefs(installed);
|
||||
const status = installedServiceRefs(installed);
|
||||
return {
|
||||
...(restartable.length === 0 ? {} : { restart: restartNativeServicesCommand(backend, restartable) }),
|
||||
...(web.length === 0 ? {} : { restartWeb: restartNativeServicesCommand(backend, web) }),
|
||||
...(sessiond.length === 0 ? {} : { restartSessiond: restartNativeServicesCommand(backend, sessiond) }),
|
||||
...(status.length === 0 ? {} : { status: statusNativeServicesCommand(backend, status) }),
|
||||
};
|
||||
}
|
||||
|
||||
function nativeServiceBackend(): NativeServiceBackendKind | undefined {
|
||||
if (process.platform === "linux" && hasCommand("systemctl")) return "systemd";
|
||||
if (process.platform === "darwin" && hasCommand("launchctl")) return "launchd";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function installedServiceIds(backend: NativeServiceBackendKind): Set<ServiceId> {
|
||||
return new Set(startServiceOrder.filter((id) => existsSync(serviceFilePath(backend, serviceRefs[id]))));
|
||||
}
|
||||
|
||||
function installedServiceRefs(installed: Set<ServiceId>, candidates: ServiceId[] = startServiceOrder): NativeServiceRef[] {
|
||||
return startServiceOrder.filter((id) => candidates.includes(id) && installed.has(id)).map((id) => serviceRefs[id]);
|
||||
}
|
||||
|
||||
function serviceFilePath(backend: NativeServiceBackendKind, ref: NativeServiceRef): string {
|
||||
return backend === "systemd" ? join(systemdServiceDir(), ref.systemdName) : join(launchdServiceDir(), ref.launchdPlistName);
|
||||
}
|
||||
|
||||
function systemdServiceDir(): string {
|
||||
return join(homedir(), ".config", "systemd", "user");
|
||||
}
|
||||
|
||||
function launchdServiceDir(): string {
|
||||
return join(homedir(), "Library", "LaunchAgents");
|
||||
}
|
||||
|
||||
function restartNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
|
||||
if (backend === "systemd") return `systemctl --user restart ${refs.map((ref) => ref.systemdName).join(" ")}`;
|
||||
return refs.map((ref) => `launchctl kickstart -k gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
|
||||
}
|
||||
|
||||
function statusNativeServicesCommand(backend: NativeServiceBackendKind, refs: NativeServiceRef[]): string {
|
||||
if (backend === "systemd") return `systemctl --user status ${refs.map((ref) => ref.systemdName).join(" ")}`;
|
||||
return refs.map((ref) => `launchctl print gui/$(id -u)/${ref.launchdLabel}`).join(" && ");
|
||||
}
|
||||
|
||||
function isGitCheckoutWithUpstream(path: string): boolean {
|
||||
return hasCommand("git")
|
||||
&& commandSucceeds("git", ["-C", path, "rev-parse", "--is-inside-work-tree"])
|
||||
&& commandSucceeds("git", ["-C", path, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
||||
}
|
||||
|
||||
function hasCommand(command: string): boolean {
|
||||
return commandSucceeds("/usr/bin/env", ["sh", "-c", `command -v ${command}`]);
|
||||
}
|
||||
|
||||
function commandSucceeds(command: string, args: string[]): boolean {
|
||||
const result = spawnSync(command, args, { encoding: "utf8" });
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
@@ -310,18 +413,23 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
|
||||
id: "update-available",
|
||||
severity: "info",
|
||||
title: "PI WEB update available",
|
||||
body: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart PI WEB services.`,
|
||||
command: commands.update,
|
||||
body: commands.update === undefined
|
||||
? `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart the services or processes for this installation.`
|
||||
: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Run the update command to update PI WEB and restart its services.`,
|
||||
...optionalMessageCommand(commands.update),
|
||||
});
|
||||
}
|
||||
|
||||
if (components.web.stale) {
|
||||
const command = commands.restartWeb ?? commands.restart;
|
||||
messages.push({
|
||||
id: "web-stale",
|
||||
severity: "warning",
|
||||
title: "Web/UI service restart needed",
|
||||
body: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
|
||||
command: commands.restart,
|
||||
body: command === undefined
|
||||
? `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the Web/UI service or process to use the installed version.`
|
||||
: `The Web/UI service is running ${formatVersion(components.web.runtimeVersion)}, but ${formatVersion(components.web.installedVersion)} is installed. Restart the service to use the installed version.`,
|
||||
...optionalMessageCommand(command),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -330,22 +438,31 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
|
||||
id: "sessiond-unavailable",
|
||||
severity: "warning",
|
||||
title: "Session daemon version unavailable",
|
||||
body: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
|
||||
command: "pi-web status",
|
||||
body: commands.status === undefined
|
||||
? `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}. Check the session daemon service or process that runs this installation.`
|
||||
: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
|
||||
...optionalMessageCommand(commands.status),
|
||||
});
|
||||
} else if (components.sessiond.stale) {
|
||||
const command = commands.restartSessiond ?? commands.restart;
|
||||
messages.push({
|
||||
id: "sessiond-stale",
|
||||
severity: "warning",
|
||||
title: "Session daemon restart needed",
|
||||
body: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
|
||||
command: commands.restart,
|
||||
body: command === undefined
|
||||
? `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the session daemon service or process to use the installed version.`
|
||||
: `The session daemon is running ${formatVersion(components.sessiond.runtimeVersion)}, but ${formatVersion(components.sessiond.installedVersion)} is installed. Restart the daemon to use the installed version.`,
|
||||
...optionalMessageCommand(command),
|
||||
});
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
function optionalMessageCommand(command: string | undefined): Pick<PiWebStatusMessage, "command"> | object {
|
||||
return command === undefined ? {} : { command };
|
||||
}
|
||||
|
||||
function skipVersionCheck(): boolean {
|
||||
return ["PI_WEB_SKIP_VERSION_CHECK", "PI_WEB_OFFLINE", "PI_SKIP_VERSION_CHECK", "PI_OFFLINE"].some((key) => {
|
||||
const value = process.env[key];
|
||||
|
||||
@@ -10,7 +10,7 @@ import { AuthService } from "./sessions/authService.js";
|
||||
import { registerAuthRoutes } from "./sessions/authRoutes.js";
|
||||
import { PiSessionService } from "./sessions/piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessions/sessionRoutes.js";
|
||||
import { sessiondSocketPath } from "./sessiond/config.js";
|
||||
import { sessiondSocketPath } from "../sessiond/config.js";
|
||||
import { TerminalService } from "./terminals/terminalService.js";
|
||||
import { registerTerminalRoutes } from "./terminals/terminalRoutes.js";
|
||||
import { getPiWebComponentStatus } from "./piWebStatus.js";
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { join } from "node:path";
|
||||
import { piWebDataDir } from "../../config.js";
|
||||
|
||||
export function sessiondSocketPath(): string {
|
||||
return process.env["PI_WEB_SESSIOND_SOCKET"] ?? join(piWebDataDir(), "sessiond.sock");
|
||||
}
|
||||
|
||||
export function sessiondHttpUrl(): string | undefined {
|
||||
return process.env["PI_WEB_SESSIOND_URL"];
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import http from "node:http";
|
||||
import { WebSocket } from "ws";
|
||||
import { sessiondHttpUrl, sessiondSocketPath } from "./config.js";
|
||||
|
||||
export class SessionDaemonClient {
|
||||
private readonly baseUrl = sessiondHttpUrl();
|
||||
private readonly socketPath = sessiondSocketPath();
|
||||
|
||||
async request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
if (this.baseUrl !== undefined && this.baseUrl !== "") return this.requestUrl(method, path, payload);
|
||||
return this.requestSocket(method, path, payload);
|
||||
}
|
||||
|
||||
connectWebSocket(path: string): WebSocket {
|
||||
if (this.baseUrl !== undefined && this.baseUrl !== "") {
|
||||
const url = new URL(path, this.baseUrl);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
return new WebSocket(url);
|
||||
}
|
||||
return new WebSocket(`ws+unix:${this.socketPath}:${path}`);
|
||||
}
|
||||
|
||||
private async requestUrl(method: string, path: string, payload?: string) {
|
||||
const init: RequestInit = { method };
|
||||
if (payload !== undefined && payload !== "") {
|
||||
init.headers = { "content-type": "application/json" };
|
||||
init.body = payload;
|
||||
}
|
||||
const response = await fetch(new URL(path, this.baseUrl), init);
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: Object.fromEntries(response.headers.entries()),
|
||||
body: await response.text(),
|
||||
};
|
||||
}
|
||||
|
||||
private requestSocket(method: string, path: string, payload?: string): Promise<{ statusCode: number; headers: Record<string, string>; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.request(
|
||||
{
|
||||
socketPath: this.socketPath,
|
||||
path,
|
||||
method,
|
||||
headers: payload !== undefined && payload !== ""
|
||||
? { "content-type": "application/json", "content-length": Buffer.byteLength(payload) }
|
||||
: undefined,
|
||||
},
|
||||
(response) => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
response.on("data", (chunk: Buffer | string) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
});
|
||||
response.on("end", () => {
|
||||
resolve({
|
||||
statusCode: response.statusCode ?? 500,
|
||||
headers: Object.fromEntries(Object.entries(response.headers).map(([key, value]) => [key, Array.isArray(value) ? value.join(", ") : value ?? ""])),
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
});
|
||||
},
|
||||
);
|
||||
request.on("error", reject);
|
||||
if (payload !== undefined && payload !== "") request.write(payload);
|
||||
request.end();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import { SessionDaemonClient } from "./sessionDaemonClient.js";
|
||||
import { SessionDaemonClient } from "../../sessiond/sessionDaemonClient.js";
|
||||
|
||||
export interface SessionProxyDaemon {
|
||||
request(method: string, path: string, body?: unknown): Promise<{ statusCode: number; headers: Record<string, string>; body: string }>;
|
||||
|
||||
@@ -45,6 +45,7 @@ function sessionRecord(id: string, cwd = "/workspace") {
|
||||
|
||||
function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {}) {
|
||||
const promptCalls: { text: string; options: unknown }[] = [];
|
||||
const listeners: ((event: unknown) => void)[] = [];
|
||||
const calls = { abort: 0, clearQueue: 0, dispose: 0, prompt: promptCalls };
|
||||
const session: TestSession = {
|
||||
sessionId,
|
||||
@@ -63,7 +64,13 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
extensionRunner: { getRegisteredCommands: () => [] },
|
||||
promptTemplates: [],
|
||||
resourceLoader: { getSkills: () => ({ skills: [] }) },
|
||||
subscribe: () => () => undefined,
|
||||
subscribe: (listener: (event: unknown) => void) => {
|
||||
listeners.push(listener);
|
||||
return () => {
|
||||
const index = listeners.indexOf(listener);
|
||||
if (index !== -1) listeners.splice(index, 1);
|
||||
};
|
||||
},
|
||||
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
|
||||
getContextUsage: () => undefined,
|
||||
prompt: (text: string, options: unknown) => {
|
||||
@@ -101,7 +108,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
|
||||
return Promise.resolve();
|
||||
},
|
||||
};
|
||||
return { runtime, session, calls };
|
||||
return { runtime, session, calls, emit: (event: unknown) => { for (const listener of [...listeners]) listener(event); } };
|
||||
}
|
||||
|
||||
function runtimeCreator(runtime: PiSessionRuntime): RuntimeCreator {
|
||||
@@ -414,6 +421,60 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("holds prompts sent during compaction until compaction finishes", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("compacting-session", { isCompacting: true });
|
||||
let resolveFirstPrompt: (() => void) | undefined;
|
||||
fake.session.prompt = (text: string, options?: { streamingBehavior?: "steer" | "followUp" }) => {
|
||||
fake.calls.prompt.push({ text, options });
|
||||
if (options === undefined) {
|
||||
fake.session.isStreaming = true;
|
||||
return new Promise<void>((resolve) => { resolveFirstPrompt = resolve; });
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
const service = new PiSessionService(hub, {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("compacting-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("compacting-session", "Start task 1", "followUp");
|
||||
await service.prompt("compacting-session", "Then task 2", "followUp");
|
||||
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append")).toBe(false);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
pendingMessageCount: 2,
|
||||
queuedMessages: [{ kind: "followUp", text: "Start task 1" }, { kind: "followUp", text: "Then task 2" }],
|
||||
});
|
||||
|
||||
fake.session.isCompacting = false;
|
||||
fake.emit({ type: "compaction_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
expect(fake.calls.prompt).toEqual([{ text: "Start task 1", options: undefined }]);
|
||||
expect(hub.sessionEvents.some(({ event }) => event.type === "message.append" && JSON.stringify(event.message).includes("Start task 1"))).toBe(true);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
pendingMessageCount: 1,
|
||||
queuedMessages: [{ kind: "followUp", text: "Then task 2" }],
|
||||
});
|
||||
|
||||
fake.emit({ type: "agent_start" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
|
||||
expect(fake.calls.prompt).toEqual([
|
||||
{ text: "Start task 1", options: undefined },
|
||||
{ text: "Then task 2", options: { streamingBehavior: "followUp" } },
|
||||
]);
|
||||
await expect(service.status("compacting-session")).resolves.toMatchObject({
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
});
|
||||
resolveFirstPrompt?.();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears queued messages when aborting active work", async () => {
|
||||
const fake = fakeRuntime("abort-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
@@ -430,6 +491,24 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears prompts queued during compaction when aborting active work", async () => {
|
||||
const fake = fakeRuntime("abort-compaction-session", { isCompacting: true });
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("abort-compaction-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt("abort-compaction-session", "Do not deliver after abort", "followUp");
|
||||
await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 1 });
|
||||
await service.abort("abort-compaction-session");
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(1);
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
await expect(service.status("abort-compaction-session")).resolves.toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("refreshes auth state and dedupes warnings when logout removes the current model's credentials", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const authStorage = AuthStorage.inMemory({ anthropic: { type: "api_key", key: "sk-test" } });
|
||||
|
||||
@@ -34,6 +34,13 @@ function authLossWarningKey(sessionId: string, provider: string, modelId: string
|
||||
return `${sessionId}:${provider}/${modelId}`;
|
||||
}
|
||||
|
||||
type QueuedPromptKind = "steer" | "followUp";
|
||||
|
||||
interface QueuedPrompt {
|
||||
kind: QueuedPromptKind;
|
||||
text: string;
|
||||
}
|
||||
|
||||
type SessionArchiveRepository = Pick<SessionArchiveStore, "list" | "get" | "archive" | "restore" | "isArchived">;
|
||||
interface PiSessionListEntry {
|
||||
id: string;
|
||||
@@ -180,6 +187,8 @@ export class PiSessionService {
|
||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
private readonly commandService: SessionCommandService<PiAgentSession>;
|
||||
private readonly compactionPromptQueues = new Map<string, QueuedPrompt[]>();
|
||||
private readonly compactionDrainTimers = new Map<string, NodeJS.Timeout>();
|
||||
private readonly authLossWarnings = new Set<string>();
|
||||
private readonly archiveStore: SessionArchiveRepository;
|
||||
private readonly agentDir: string;
|
||||
@@ -222,9 +231,11 @@ export class PiSessionService {
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
clearInterval(this.heartbeat);
|
||||
this.clearCompactionDrainTimers();
|
||||
const activeSessions = Array.from(new Set(this.active.values()));
|
||||
this.active.clear();
|
||||
this.activities.clear();
|
||||
this.compactionPromptQueues.clear();
|
||||
this.authLossWarnings.clear();
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
@@ -355,18 +366,36 @@ export class PiSessionService {
|
||||
this.maybeGenerateSessionName(session, text);
|
||||
const isQueued = session.isStreaming || session.isCompacting;
|
||||
const behavior = isQueued ? streamingBehavior ?? "followUp" : undefined;
|
||||
if (isQueued && hasQueuedMessageText(session, text)) {
|
||||
if (isQueued && this.hasQueuedMessageText(session, text)) {
|
||||
this.publishActivity(session, "duplicate queued message ignored", "active");
|
||||
this.publishStatus(session);
|
||||
return;
|
||||
}
|
||||
this.publishActivity(session, session.isCompacting ? "message queued during compaction" : behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
||||
if (!isQueued) this.events.publish(sessionId, { type: "message.append", message: userTextMessage(text) });
|
||||
void session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
|
||||
if (session.isCompacting) {
|
||||
this.enqueuePromptDuringCompaction(session, text, behavior ?? "followUp");
|
||||
return;
|
||||
}
|
||||
void this.submitPrompt(session, text, behavior);
|
||||
}
|
||||
|
||||
private submitPrompt(session: PiAgentSession, text: string, behavior: QueuedPromptKind | undefined): Promise<void> {
|
||||
this.publishActivity(session, behavior === "steer" ? "steering queued" : behavior === "followUp" ? "message queued" : "prompt accepted", "active");
|
||||
if (behavior === undefined) this.events.publish(session.sessionId, { type: "message.append", message: userTextMessage(text) });
|
||||
const promptPromise = session.prompt(text, behavior === undefined ? undefined : { streamingBehavior: behavior }).catch((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.publishActivity(session, "error", "error", message);
|
||||
this.events.publish(sessionId, { type: "session.error", message });
|
||||
this.events.publish(session.sessionId, { type: "session.error", message });
|
||||
});
|
||||
void promptPromise;
|
||||
return promptPromise;
|
||||
}
|
||||
|
||||
private enqueuePromptDuringCompaction(session: PiAgentSession, text: string, kind: QueuedPromptKind): void {
|
||||
const queue = this.compactionPromptQueues.get(session.sessionId) ?? [];
|
||||
queue.push({ kind, text });
|
||||
this.compactionPromptQueues.set(session.sessionId, queue);
|
||||
this.publishActivity(session, "message queued during compaction", "active");
|
||||
this.publishStatus(session);
|
||||
}
|
||||
|
||||
async shell(sessionId: string, text: string): Promise<void> {
|
||||
@@ -416,7 +445,7 @@ export class PiSessionService {
|
||||
|
||||
async archive(sessionId: string): Promise<void> {
|
||||
const session = await this.getOrOpen(sessionId);
|
||||
if (sessionHasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
|
||||
const archiveInput = await this.archiveInputForSession(session);
|
||||
await this.closeActive(session.sessionId);
|
||||
await this.archiveStore.archive(archiveInput);
|
||||
@@ -427,7 +456,7 @@ export class PiSessionService {
|
||||
const catalog = await this.workspaceArchiveCandidates(session.sessionManager.getCwd());
|
||||
const root = findArchiveCandidateByIdOrPrefix(catalog, session.sessionId) ?? archiveCandidateFromActiveSession(session, false);
|
||||
const plan = planSessionArchiveTree(root, catalog);
|
||||
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && sessionHasActiveWork(target));
|
||||
const busy = plan.targets.map((target) => target.activeSession).find((target) => target !== undefined && this.hasActiveWork(target));
|
||||
if (busy !== undefined) throw new Error(`Stop current session activity before archiving ${sessionDisplayName(busy)}`);
|
||||
|
||||
const archiveInputs = plan.unarchivedTargets.map((target) => archiveInputFromCandidate(target));
|
||||
@@ -457,6 +486,7 @@ export class PiSessionService {
|
||||
async abort(sessionId: string): Promise<void> {
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
await active.runtime.session.abort();
|
||||
this.publishActivity(active.runtime.session, "stopped", "idle");
|
||||
@@ -551,6 +581,7 @@ export class PiSessionService {
|
||||
this.activities.delete(sessionId);
|
||||
this.workspaceActivity?.removeSession(sessionId, active.runtime.session.sessionManager.getCwd());
|
||||
this.clearAuthLossWarningsForSession(sessionId);
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
clearSessionQueue(active.runtime.session);
|
||||
active.unsubscribe();
|
||||
try {
|
||||
@@ -595,18 +626,84 @@ export class PiSessionService {
|
||||
|
||||
private bindRuntime(active: ActiveSession<PiSessionRuntime>): void {
|
||||
active.unsubscribe();
|
||||
for (const [sessionId, candidate] of this.active.entries()) {
|
||||
if (candidate === active) this.active.delete(sessionId);
|
||||
}
|
||||
const { session } = active.runtime;
|
||||
for (const [sessionId, candidate] of this.active.entries()) {
|
||||
if (candidate === active) {
|
||||
this.active.delete(sessionId);
|
||||
if (sessionId !== session.sessionId) this.clearCompactionPromptQueue(sessionId);
|
||||
}
|
||||
}
|
||||
active.unsubscribe = session.subscribe((event) => {
|
||||
this.events.publish(session.sessionId, toClientEvent(event));
|
||||
this.publishActivityForEvent(session, event);
|
||||
const eventType = getString(event, "type");
|
||||
if (eventType === "compaction_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
||||
if (eventType === "agent_start" || eventType === "agent_end") this.scheduleCompactionQueueDrain(session.sessionId);
|
||||
this.publishStatus(session);
|
||||
});
|
||||
this.active.set(session.sessionId, active);
|
||||
}
|
||||
|
||||
private scheduleCompactionQueueDrain(sessionId: string, delayMs = 0): void {
|
||||
if (!this.compactionPromptQueues.has(sessionId) || this.compactionDrainTimers.has(sessionId)) return;
|
||||
const timer = setTimeout(() => {
|
||||
this.compactionDrainTimers.delete(sessionId);
|
||||
this.drainCompactionPromptQueue(sessionId);
|
||||
}, delayMs);
|
||||
this.compactionDrainTimers.set(sessionId, timer);
|
||||
}
|
||||
|
||||
private drainCompactionPromptQueue(sessionId: string): void {
|
||||
const active = this.active.get(sessionId);
|
||||
if (active === undefined) return;
|
||||
const { session } = active.runtime;
|
||||
if (session.isCompacting) {
|
||||
this.scheduleCompactionQueueDrain(sessionId, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.isStreaming) {
|
||||
const queued = this.takeCompactionPromptQueue(sessionId);
|
||||
if (queued.length === 0) return;
|
||||
this.publishStatus(session);
|
||||
for (const prompt of queued) void this.submitPrompt(session, prompt.text, prompt.kind);
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = this.shiftCompactionPrompt(sessionId);
|
||||
if (prompt === undefined) return;
|
||||
this.publishStatus(session);
|
||||
const submitted = this.submitPrompt(session, prompt.text, undefined);
|
||||
void submitted.finally(() => { this.scheduleCompactionQueueDrain(sessionId); });
|
||||
}
|
||||
|
||||
private takeCompactionPromptQueue(sessionId: string): QueuedPrompt[] {
|
||||
const queued = this.compactionPromptQueues.get(sessionId) ?? [];
|
||||
this.compactionPromptQueues.delete(sessionId);
|
||||
return queued;
|
||||
}
|
||||
|
||||
private shiftCompactionPrompt(sessionId: string): QueuedPrompt | undefined {
|
||||
const queue = this.compactionPromptQueues.get(sessionId);
|
||||
const prompt = queue?.shift();
|
||||
if (queue === undefined || queue.length === 0) this.compactionPromptQueues.delete(sessionId);
|
||||
return prompt;
|
||||
}
|
||||
|
||||
private clearCompactionPromptQueue(sessionId: string): void {
|
||||
this.compactionPromptQueues.delete(sessionId);
|
||||
const timer = this.compactionDrainTimers.get(sessionId);
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer);
|
||||
this.compactionDrainTimers.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
private clearCompactionDrainTimers(): void {
|
||||
for (const timer of this.compactionDrainTimers.values()) clearTimeout(timer);
|
||||
this.compactionDrainTimers.clear();
|
||||
}
|
||||
|
||||
private maybeGenerateSessionName(session: PiAgentSession, firstMessage: string): void {
|
||||
if (session.sessionName !== undefined || session.messages.length !== 0 || session.isStreaming || session.isCompacting) return;
|
||||
const model = session.model;
|
||||
@@ -674,7 +771,7 @@ export class PiSessionService {
|
||||
for (const active of this.active.values()) {
|
||||
const { session } = active.runtime;
|
||||
const activity = this.activities.get(session.sessionId);
|
||||
if (!sessionHasActiveWork(session)) {
|
||||
if (!this.hasActiveWork(session)) {
|
||||
if (activity?.phase === "active") this.publishStatus(session);
|
||||
continue;
|
||||
}
|
||||
@@ -688,10 +785,14 @@ export class PiSessionService {
|
||||
if (session.isCompacting) return "compacting";
|
||||
if (session.isBashRunning) return "running bash";
|
||||
if (session.isStreaming) return "agent running";
|
||||
if (session.pendingMessageCount) return "queued";
|
||||
if (this.pendingMessageCount(session) > 0) return "queued";
|
||||
return "active";
|
||||
}
|
||||
|
||||
private hasActiveWork(session: PiAgentSession): boolean {
|
||||
return sessionHasActiveWork(session, this.compactionQueuedMessages(session.sessionId).length);
|
||||
}
|
||||
|
||||
private publishActivityForEvent(session: PiAgentSession, event: unknown): void {
|
||||
const eventType = getString(event, "type");
|
||||
if (eventType === undefined) return;
|
||||
@@ -716,7 +817,7 @@ export class PiSessionService {
|
||||
}
|
||||
if (eventType === "bash_execution_start") { this.publishActivity(session, "running bash", "active"); return; }
|
||||
if (eventType === "bash_execution_end") { this.publishActivity(session, "bash complete", "idle"); return; }
|
||||
if (sessionHasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
||||
if (this.hasActiveWork(session)) this.publishActivity(session, eventType.replaceAll("_", " "), "active");
|
||||
}
|
||||
|
||||
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
||||
@@ -739,7 +840,7 @@ export class PiSessionService {
|
||||
|
||||
private clearStaleActiveActivity(session: PiAgentSession): void {
|
||||
const current = this.activities.get(session.sessionId);
|
||||
if (current?.phase !== "active" || sessionHasActiveWork(session)) return;
|
||||
if (current?.phase !== "active" || this.hasActiveWork(session)) return;
|
||||
const at = new Date().toISOString();
|
||||
const stored = { phase: "idle" as const, label: "idle", at };
|
||||
this.activities.set(session.sessionId, stored);
|
||||
@@ -759,14 +860,26 @@ export class PiSessionService {
|
||||
isStreaming: session.isStreaming,
|
||||
isCompacting: session.isCompacting,
|
||||
isBashRunning: session.isBashRunning,
|
||||
pendingMessageCount: session.pendingMessageCount,
|
||||
queuedMessages: queuedMessagesFromSession(session),
|
||||
pendingMessageCount: this.pendingMessageCount(session),
|
||||
queuedMessages: queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)),
|
||||
messageCount: session.messages.length,
|
||||
tokens: stats.tokens,
|
||||
cost: stats.cost,
|
||||
...(contextUsage === undefined ? {} : { contextUsage }),
|
||||
};
|
||||
}
|
||||
|
||||
private pendingMessageCount(session: PiAgentSession): number {
|
||||
return session.pendingMessageCount + this.compactionQueuedMessages(session.sessionId).length;
|
||||
}
|
||||
|
||||
private compactionQueuedMessages(sessionId: string): readonly QueuedPrompt[] {
|
||||
return this.compactionPromptQueues.get(sessionId) ?? [];
|
||||
}
|
||||
|
||||
private hasQueuedMessageText(session: PiAgentSession, text: string): boolean {
|
||||
return queuedMessagesFromSession(session, this.compactionQueuedMessages(session.sessionId)).some((message) => message.text === text);
|
||||
}
|
||||
}
|
||||
|
||||
function modelToClientModel(model: PiAgentSession["model"]): ClientSessionModel {
|
||||
@@ -872,8 +985,8 @@ function archiveInputFromCandidate(candidate: WorkspaceArchiveCandidate): Archiv
|
||||
throw new Error(`Session is not available for archiving: ${candidate.id}`);
|
||||
}
|
||||
|
||||
function sessionHasActiveWork(session: PiAgentSession): boolean {
|
||||
return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount > 0;
|
||||
function sessionHasActiveWork(session: PiAgentSession, extraQueuedMessageCount = 0): boolean {
|
||||
return session.isStreaming || session.isCompacting || session.isBashRunning || session.pendingMessageCount + extraQueuedMessageCount > 0;
|
||||
}
|
||||
|
||||
function sessionDisplayName(session: PiAgentSession): string {
|
||||
@@ -938,14 +1051,11 @@ function clearSessionQueue(session: PiAgentSession): void {
|
||||
session.clearQueue();
|
||||
}
|
||||
|
||||
function hasQueuedMessageText(session: PiAgentSession, text: string): boolean {
|
||||
return queuedMessagesFromSession(session).some((message) => message.text === text);
|
||||
}
|
||||
|
||||
function queuedMessagesFromSession(session: PiAgentSession): { kind: "steer" | "followUp"; text: string }[] {
|
||||
function queuedMessagesFromSession(session: PiAgentSession, extraQueuedMessages: readonly QueuedPrompt[] = []): { kind: "steer" | "followUp"; text: string }[] {
|
||||
return [
|
||||
...session.getSteeringMessages().map((text) => ({ kind: "steer" as const, text })),
|
||||
...session.getFollowUpMessages().map((text) => ({ kind: "followUp" as const, text })),
|
||||
...extraQueuedMessages,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance, FastifyReply } from "fastify";
|
||||
import type { ProjectService } from "./projects/projectService.js";
|
||||
import { SessionDaemonClient } from "./sessiond/sessionDaemonClient.js";
|
||||
import { SessionDaemonClient } from "../sessiond/sessionDaemonClient.js";
|
||||
import type { SessionProxyDaemon } from "./sessiond/sessionProxyRoutes.js";
|
||||
import { resolveWorkspaceContext } from "./workspaces/workspaceContext.js";
|
||||
import type { WorkspaceService } from "./workspaces/workspaceService.js";
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { listFileSuggestions, type FileSuggestionDependencies } from "./fileSuggestions";
|
||||
|
||||
const temporaryRoots: string[] = [];
|
||||
|
||||
async function tempWorkspace(): Promise<string> {
|
||||
const root = await mkdtemp(join(tmpdir(), "pi-web-files-"));
|
||||
temporaryRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
describe("file suggestions", () => {
|
||||
it("uses tracked git files for tracked-scope suggestions", async () => {
|
||||
const calls: { file: string; args: string[] }[] = [];
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
calls.push({ file, args });
|
||||
if (file === "git" && args.join(" ") === "ls-files") return Promise.resolve({ stdout: "src/app.ts\nREADME.md\n" });
|
||||
return Promise.reject(new Error(`unexpected command: ${file} ${args.join(" ")}`));
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions("/repo", "", { scope: "tracked" }, deps)).resolves.toEqual([
|
||||
{ path: "src/", kind: "tracked" },
|
||||
{ path: "README.md", kind: "tracked" },
|
||||
{ path: "src/app.ts", kind: "tracked" },
|
||||
]);
|
||||
expect(calls).toEqual([{ file: "git", args: ["ls-files"] }]);
|
||||
});
|
||||
|
||||
it("asks ripgrep for hidden and ignored files in all-file scope", async () => {
|
||||
const calls: { file: string; args: string[] }[] = [];
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file, args) => {
|
||||
calls.push({ file, args });
|
||||
return Promise.resolve({ stdout: "node_modules/pkg/index.js\nsrc/app.ts\n" });
|
||||
},
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions("/repo", "pkg", { scope: "all" }, deps)).resolves.toEqual([
|
||||
{ path: "node_modules/pkg/", kind: "other" },
|
||||
{ path: "node_modules/pkg/index.js", kind: "other" },
|
||||
]);
|
||||
expect(calls).toEqual([{ file: "rg", args: ["--files", "--hidden", "--no-ignore"] }]);
|
||||
});
|
||||
|
||||
it("falls back to a bounded filesystem scan without directory exclusions when git and rg are unavailable", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "src"), { recursive: true });
|
||||
await mkdir(join(root, "node_modules", "pkg"), { recursive: true });
|
||||
await writeFile(join(root, "README.md"), "hello");
|
||||
await writeFile(join(root, "src", "app.ts"), "export {};\n");
|
||||
await writeFile(join(root, "node_modules", "pkg", "index.js"), "module.exports = {};\n");
|
||||
|
||||
const deps: FileSuggestionDependencies = {
|
||||
execFile: (file) => Promise.reject(Object.assign(new Error(`spawn ${file} ENOENT`), { code: "ENOENT" })),
|
||||
};
|
||||
|
||||
await expect(listFileSuggestions(root, "", { scope: "all" }, deps)).resolves.toEqual([
|
||||
{ path: "node_modules/", kind: "other" },
|
||||
{ path: "node_modules/pkg/", kind: "other" },
|
||||
{ path: "src/", kind: "other" },
|
||||
{ path: "node_modules/pkg/index.js", kind: "other" },
|
||||
{ path: "README.md", kind: "other" },
|
||||
{ path: "src/app.ts", kind: "other" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,33 @@ import { sanitizedGitEnv } from "../git/gitEnv.js";
|
||||
import type { ClientFileSuggestion } from "../types.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const commandMaxBuffer = 1024 * 1024 * 8;
|
||||
const maxFilesystemFallbackPaths = 20_000;
|
||||
|
||||
export async function listFileSuggestions(cwd: string, query = "", kind?: ClientFileSuggestion["kind"]): Promise<ClientFileSuggestion[]> {
|
||||
const normalizedQuery = query.replace(/^@/, "").toLowerCase();
|
||||
const files = await listGitFiles(cwd).catch(() => listPlainFiles(cwd));
|
||||
interface ExecFileOptions {
|
||||
cwd: string;
|
||||
maxBuffer: number;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}
|
||||
|
||||
export type FileSuggestionScope = "tracked" | "all";
|
||||
|
||||
export interface FileSuggestionOptions {
|
||||
kind?: ClientFileSuggestion["kind"] | undefined;
|
||||
scope?: FileSuggestionScope | undefined;
|
||||
}
|
||||
|
||||
export interface FileSuggestionDependencies {
|
||||
execFile?: (file: string, args: string[], options: ExecFileOptions) => Promise<{ stdout: string }>;
|
||||
}
|
||||
|
||||
export async function listFileSuggestions(cwd: string, query = "", options: FileSuggestionOptions = {}, deps: FileSuggestionDependencies = {}): Promise<ClientFileSuggestion[]> {
|
||||
const normalizedQuery = normalizeFileQuery(query);
|
||||
const exec = deps.execFile ?? execFileAsync;
|
||||
const files = await listFilesForScope(cwd, options.scope, exec);
|
||||
return files
|
||||
.filter((file) => !kind || file.kind === kind)
|
||||
.filter((file) => !normalizedQuery || file.path.toLowerCase().includes(normalizedQuery))
|
||||
.filter((file) => options.kind === undefined || file.kind === options.kind)
|
||||
.filter((file) => normalizedQuery === "" || file.path.toLowerCase().includes(normalizedQuery))
|
||||
.sort((a, b) => Number(!a.path.endsWith("/")) - Number(!b.path.endsWith("/")) || a.path.localeCompare(b.path))
|
||||
.slice(0, 80);
|
||||
}
|
||||
@@ -40,10 +60,20 @@ export async function listPathSuggestions(cwd: string, prefix = ""): Promise<Cli
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
||||
async function listFilesForScope(cwd: string, scope: FileSuggestionScope | undefined, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
if (scope === "all") return listPlainFiles(cwd, exec, true);
|
||||
if (scope === "tracked") return listTrackedFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, true));
|
||||
return listGitFiles(cwd, exec).catch(() => listPlainFiles(cwd, exec, false));
|
||||
}
|
||||
|
||||
async function listTrackedFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
return withDirectories(lines(await git(cwd, ["ls-files"], exec)), "tracked");
|
||||
}
|
||||
|
||||
async function listGitFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<ClientFileSuggestion[]> {
|
||||
const [tracked, untracked] = await Promise.all([
|
||||
git(cwd, ["ls-files"]),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard"]),
|
||||
git(cwd, ["ls-files"], exec),
|
||||
git(cwd, ["ls-files", "--others", "--exclude-standard"], exec),
|
||||
]);
|
||||
return [
|
||||
...withDirectories(lines(tracked), "tracked"),
|
||||
@@ -51,16 +81,63 @@ async function listGitFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
||||
];
|
||||
}
|
||||
|
||||
async function listPlainFiles(cwd: string): Promise<ClientFileSuggestion[]> {
|
||||
const { stdout } = await execFileAsync("rg", ["--files"], { cwd, maxBuffer: 1024 * 1024 * 8 });
|
||||
return withDirectories(lines(stdout), "other");
|
||||
async function listPlainFiles(cwd: string, exec: NonNullable<FileSuggestionDependencies["execFile"]>, includeIgnored: boolean): Promise<ClientFileSuggestion[]> {
|
||||
try {
|
||||
const args = includeIgnored ? ["--files", "--hidden", "--no-ignore"] : ["--files"];
|
||||
const { stdout } = await exec("rg", args, { cwd, maxBuffer: commandMaxBuffer });
|
||||
return withDirectories(lines(stdout), "other");
|
||||
} catch {
|
||||
return withDirectories(await filesystemFiles(cwd), "other");
|
||||
}
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[]): Promise<string> {
|
||||
const { stdout } = await execFileAsync("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: 1024 * 1024 * 8 });
|
||||
async function filesystemFiles(cwd: string): Promise<string[]> {
|
||||
const paths: string[] = [];
|
||||
await collectFilesystemFiles(cwd, "", paths, false);
|
||||
return paths;
|
||||
}
|
||||
|
||||
async function collectFilesystemFiles(cwd: string, relativeDirectory: string, paths: string[], optionalDirectory: boolean): Promise<void> {
|
||||
if (paths.length >= maxFilesystemFallbackPaths) return;
|
||||
const absoluteDirectory = relativeDirectory === "" ? cwd : join(cwd, relativeDirectory);
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(absoluteDirectory, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (optionalDirectory) return;
|
||||
throw error;
|
||||
}
|
||||
|
||||
entries.sort((a, b) => Number(!a.isDirectory()) - Number(!b.isDirectory()) || a.name.localeCompare(b.name));
|
||||
for (const entry of entries) {
|
||||
if (paths.length >= maxFilesystemFallbackPaths) return;
|
||||
const relativePath = relativeDirectory === "" ? entry.name : `${relativeDirectory}/${entry.name}`;
|
||||
if (entry.isDirectory()) {
|
||||
await collectFilesystemFiles(cwd, relativePath, paths, true);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() || await isSymlinkedFile(cwd, relativePath, entry.isSymbolicLink())) paths.push(relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function isSymlinkedFile(cwd: string, relativePath: string, symbolicLink: boolean): Promise<boolean> {
|
||||
if (!symbolicLink) return false;
|
||||
try {
|
||||
return (await stat(join(cwd, relativePath))).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function git(cwd: string, args: string[], exec: NonNullable<FileSuggestionDependencies["execFile"]>): Promise<string> {
|
||||
const { stdout } = await exec("git", args, { cwd, env: sanitizedGitEnv(), maxBuffer: commandMaxBuffer });
|
||||
return stdout;
|
||||
}
|
||||
|
||||
function normalizeFileQuery(query: string): string {
|
||||
return query.replace(/^!@/, "").replace(/^@\s?/, "").toLowerCase();
|
||||
}
|
||||
|
||||
function lines(text: string): string[] {
|
||||
return text.split("\n").map((line) => line.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("listWorkspaceTree", () => {
|
||||
it("lists visible entries with directories first, sorted by name", async () => {
|
||||
it("lists entries with directories first, sorted by name", async () => {
|
||||
const root = await tempWorkspace();
|
||||
await mkdir(join(root, "z-dir"));
|
||||
await mkdir(join(root, "a-dir"));
|
||||
@@ -32,7 +32,9 @@ describe("listWorkspaceTree", () => {
|
||||
expect(tree.path).toBe("");
|
||||
expect(tree.truncated).toBe(false);
|
||||
expect(tree.entries.map((entry) => [entry.name, entry.type])).toEqual([
|
||||
[".git", "directory"],
|
||||
["a-dir", "directory"],
|
||||
["node_modules", "directory"],
|
||||
["z-dir", "directory"],
|
||||
["a.txt", "file"],
|
||||
["b.txt", "file"],
|
||||
|
||||
@@ -11,11 +11,11 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
|
||||
if (!stat.isDirectory()) throw new Error("Path is not a directory");
|
||||
|
||||
const dirents = await readdir(target, { withFileTypes: true });
|
||||
const visible = dirents.filter((entry) => entry.name !== ".git" && entry.name !== "node_modules").sort((a, b) => {
|
||||
const sorted = dirents.sort((a, b) => {
|
||||
if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
const selected = visible.slice(0, MAX_ENTRIES);
|
||||
const selected = sorted.slice(0, MAX_ENTRIES);
|
||||
const entries = await Promise.all(selected.map(async (entry): Promise<FileTreeEntry> => {
|
||||
const absolute = join(target, entry.name);
|
||||
const childRelative = relativePath === "" ? entry.name : `${relativePath}/${entry.name}`;
|
||||
@@ -24,5 +24,5 @@ export async function listWorkspaceTree(rootPath: string, path: string | undefin
|
||||
return { name: entry.name, path: childRelative, type, size: childStat.size, modifiedAt: childStat.mtime.toISOString() };
|
||||
}));
|
||||
|
||||
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: visible.length > selected.length };
|
||||
return { path: relativePath, entries, scannedAt: new Date().toISOString(), truncated: sorted.length > selected.length };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user