mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: separate frontend and backend services
This commit is contained in:
@@ -3,7 +3,7 @@ import { dirname, join, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const rootDir = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||
const webDir = join(rootDir, "pi-web");
|
||||
const serverDir = join(rootDir, "server");
|
||||
const requiredProfilePaths = [
|
||||
join(rootDir, "config", "settings.default.json"),
|
||||
join(rootDir, "config", "mcp.default.json"),
|
||||
@@ -37,7 +37,7 @@ function readJson(path) {
|
||||
}
|
||||
}
|
||||
|
||||
const webPackage = readJson(join(webDir, "package.json"));
|
||||
const serverPackage = readJson(join(serverDir, "package.json"));
|
||||
const failures = [];
|
||||
const versions = new Set();
|
||||
|
||||
@@ -47,13 +47,13 @@ for (const path of requiredProfilePaths) {
|
||||
|
||||
for (const [packageName, packageDirName] of packages) {
|
||||
const localDir = resolve(rootDir, "pi", "packages", packageDirName);
|
||||
const installedDir = resolve(webDir, "node_modules", ...packageName.split("/"));
|
||||
const installedDir = resolve(serverDir, "node_modules", ...packageName.split("/"));
|
||||
const localPackagePath = join(localDir, "package.json");
|
||||
const installedPackagePath = join(installedDir, "package.json");
|
||||
const expectedSpec = `file:../pi/packages/${packageDirName}`;
|
||||
|
||||
if (webPackage.dependencies?.[packageName] !== expectedSpec) {
|
||||
failures.push(`${packageName} must use ${expectedSpec}`);
|
||||
if (serverPackage.dependencies?.[packageName] !== expectedSpec) {
|
||||
failures.push(`${packageName} must use ${expectedSpec} in server/package.json`);
|
||||
continue;
|
||||
}
|
||||
if (!existsSync(localPackagePath)) {
|
||||
@@ -93,4 +93,4 @@ if (failures.length > 0) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("Local Pi backend packages are built and installed into Pi Web.");
|
||||
console.log("Local Pi backend packages are built and installed into Pi Agent Server.");
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Object.assign(process.env, managedEnvironment());
|
||||
|
||||
const codingAgentEntry = pathToFileURL(
|
||||
join(rootDir, "pi-web", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"),
|
||||
join(rootDir, "server", "node_modules", "@earendil-works", "pi-coding-agent", "dist", "index.js"),
|
||||
).href;
|
||||
const { DefaultResourceLoader } = await import(codingAgentEntry);
|
||||
|
||||
|
||||
@@ -64,15 +64,16 @@ export function isPiAiVisionConfigured(source) {
|
||||
|
||||
export function defaultPiAiDir() {
|
||||
// Managed installs place packages under <agentDir>/npm/node_modules; pi-ai is
|
||||
// also a direct dependency of pi-web, which wins in dev. Prefer pi-web's copy.
|
||||
// also a direct dependency of the backend (server/), which wins at runtime.
|
||||
// Prefer the server's copy.
|
||||
const candidates = [
|
||||
resolve(process.cwd(), "pi-web", "node_modules", "@earendil-works", "pi-ai"),
|
||||
resolve(process.cwd(), "server", "node_modules", "@earendil-works", "pi-ai"),
|
||||
resolve(process.cwd(), "node_modules", "@earendil-works", "pi-ai"),
|
||||
];
|
||||
for (const candidate of candidates) {
|
||||
if (existsSync(join(candidate, "package.json"))) return candidate;
|
||||
}
|
||||
throw new Error("pi-ai package not found under pi-web/node_modules or node_modules");
|
||||
throw new Error("pi-ai package not found under server/node_modules or node_modules");
|
||||
}
|
||||
|
||||
export function configurePiAiVision({ piAiDir = defaultPiAiDir(), quiet = false } = {}) {
|
||||
|
||||
+8
-8
@@ -55,10 +55,10 @@ const seedFiles = [
|
||||
];
|
||||
|
||||
const persistedWindowsEnvironmentKeys = ["SEARXNG_TOKEN", "SEARXNG_URL"];
|
||||
const piWebBinDir = join(rootDir, "pi-web", "node_modules", ".bin");
|
||||
const serverBinDir = join(rootDir, "server", "node_modules", ".bin");
|
||||
const piCodingAgentPackageDir = join(
|
||||
rootDir,
|
||||
"pi-web",
|
||||
"server",
|
||||
"node_modules",
|
||||
"@earendil-works",
|
||||
"pi-coding-agent",
|
||||
@@ -73,7 +73,7 @@ const managedPeerPackageDir = join(
|
||||
|
||||
function ensurePiSubagentRuntime() {
|
||||
// pi-subagents resolves the host CLI through its optional coding-agent peer.
|
||||
// The managed profile is installed separately from pi-web, so expose the
|
||||
// The managed profile is installed separately from the backend, so expose the
|
||||
// existing local package instead of installing a second coding-agent copy.
|
||||
if (!existsSync(piCodingAgentPackageDir)) return;
|
||||
|
||||
@@ -97,12 +97,12 @@ function ensurePiSubagentRuntime() {
|
||||
}
|
||||
}
|
||||
|
||||
function prependPiWebBinToPath(baseEnv) {
|
||||
if (!existsSync(piWebBinDir)) return baseEnv.PATH ?? baseEnv.Path;
|
||||
const pathKey = baseEnv.PATH !== undefined || baseEnv.Path === undefined ? "PATH" : "Path";
|
||||
function prependServerBinToPath(baseEnv) {
|
||||
if (!existsSync(serverBinDir)) return baseEnv.PATH ?? baseEnv.Path;
|
||||
const pathKey = baseEnv.PATH !== undefined && baseEnv.Path === undefined ? "PATH" : "Path";
|
||||
const currentPath = baseEnv[pathKey] ?? "";
|
||||
const entries = currentPath.split(delimiter).filter(Boolean);
|
||||
if (!entries.includes(piWebBinDir)) entries.unshift(piWebBinDir);
|
||||
if (!entries.includes(serverBinDir)) entries.unshift(serverBinDir);
|
||||
return entries.join(delimiter);
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export function managedEnvironment(baseEnv = process.env) {
|
||||
return {
|
||||
...baseEnv,
|
||||
...persistedEnvironment,
|
||||
PATH: prependPiWebBinToPath({ ...baseEnv, ...persistedEnvironment }),
|
||||
PATH: prependServerBinToPath({ ...baseEnv, ...persistedEnvironment }),
|
||||
PI_AGENT_MANAGED_RUNTIME: "1",
|
||||
PI_AGENT_APP_ROOT: rootDir,
|
||||
PI_AGENT_DATA_DIR: dataDir,
|
||||
|
||||
+131
-22
@@ -1,4 +1,5 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { configurePiMemory } from "./configure-pi-memory.mjs";
|
||||
import { configurePiAiVision } from "./configure-pi-ai-vision.mjs";
|
||||
@@ -23,6 +24,7 @@ if (!npmCliPath) {
|
||||
}
|
||||
|
||||
const childEnvironment = managedEnvironment();
|
||||
const serverPort = Number(childEnvironment.PI_SERVER_PORT ?? "30142");
|
||||
const memoryConfiguration = configurePiMemory({ quiet: true });
|
||||
if (memoryConfiguration.status === "missing") {
|
||||
console.warn("[memory] pi-memory is not installed; run npm run setup to enable managed memory");
|
||||
@@ -38,28 +40,135 @@ if (await isLocalPortListening(30141)) {
|
||||
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
|
||||
}
|
||||
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[npmCliPath, "--prefix", join(rootDir, "pi-web"), "run", target],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env: childEnvironment,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
const children = [];
|
||||
let shuttingDown = false;
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => child.kill(signal));
|
||||
function spawnPackageScript(packageDir, script) {
|
||||
return spawn(
|
||||
process.execPath,
|
||||
[npmCliPath, "--prefix", join(rootDir, packageDir), "run", script],
|
||||
{
|
||||
cwd: rootDir,
|
||||
env: childEnvironment,
|
||||
stdio: "inherit",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!signal && code === 0 && target === "build") {
|
||||
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
|
||||
function killChild(child, signal) {
|
||||
if (child.killed || child.exitCode !== null) return;
|
||||
if (platform() === "win32") {
|
||||
// Windows has no process groups: killing the npm wrapper leaves the
|
||||
// real workers (tsx watch / next dev) orphaned and holding their ports.
|
||||
// taskkill /T terminates the whole tree instead.
|
||||
spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { windowsHide: true });
|
||||
} else {
|
||||
child.kill(signal);
|
||||
}
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
}
|
||||
|
||||
function stopAll(signal) {
|
||||
shuttingDown = true;
|
||||
for (const child of children) {
|
||||
killChild(child, signal);
|
||||
}
|
||||
}
|
||||
|
||||
// TCP listening alone is not enough: an orphaned backend from a previous run
|
||||
// can hold the port. Verify identity through the health endpoint instead.
|
||||
async function isBackendHealthy() {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${serverPort}/api/health`, {
|
||||
signal: AbortSignal.timeout(750),
|
||||
});
|
||||
if (!response.ok) return false;
|
||||
const body = await response.json();
|
||||
return body?.name === "pi-agent-server";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.on(signal, () => stopAll(signal));
|
||||
}
|
||||
|
||||
if (target === "build") {
|
||||
// Sequential builds: the backend is independent, the frontend only needs
|
||||
// the shared types package, so order matters only for readable logs.
|
||||
const serverBuild = spawnPackageScript("server", "build");
|
||||
serverBuild.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
serverBuild.on("exit", (code, signal) => {
|
||||
if (signal || code !== 0) process.exit(code ?? 1);
|
||||
const webBuild = spawnPackageScript("pi-web", "build");
|
||||
webBuild.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
webBuild.on("exit", (code, signal) => {
|
||||
if (!signal && code === 0) {
|
||||
printMaintenanceResult(maintainStorage({ mode: "auto", env: childEnvironment }));
|
||||
}
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Backend first: agent sessions live in pi-agent-server, and Pi Web's
|
||||
// /api rewrites need it listening before the first request arrives.
|
||||
if (await isLocalPortListening(serverPort)) {
|
||||
console.error(
|
||||
`[run] port ${serverPort} is already in use (possibly an orphaned backend). ` +
|
||||
`Find it with: netstat -ano | findstr :${serverPort}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const serverScript = target.startsWith("dev") ? "dev" : "start";
|
||||
const server = spawnPackageScript("server", serverScript);
|
||||
children.push(server);
|
||||
server.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
server.on("exit", (code, signal) => {
|
||||
if (shuttingDown) return;
|
||||
console.error(`[run] backend exited (${code ?? signal}); shutting down Pi Web`);
|
||||
stopAll(signal ?? "SIGTERM");
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
|
||||
const startWeb = () => {
|
||||
const web = spawnPackageScript("pi-web", target);
|
||||
children.push(web);
|
||||
web.on("error", (error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
web.on("exit", (code, signal) => {
|
||||
stopAll(signal ?? "SIGTERM");
|
||||
if (signal) process.kill(process.pid, signal);
|
||||
process.exit(code ?? 1);
|
||||
});
|
||||
};
|
||||
|
||||
let webStarted = false;
|
||||
const deadline = Date.now() + 30_000;
|
||||
while (Date.now() < deadline) {
|
||||
if (server.exitCode !== null || server.killed) break;
|
||||
if (await isBackendHealthy()) {
|
||||
webStarted = true;
|
||||
startWeb();
|
||||
break;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 250));
|
||||
}
|
||||
if (!webStarted) {
|
||||
console.error(`[run] backend did not become ready on port ${serverPort} within 30s`);
|
||||
stopAll("SIGTERM");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -91,17 +91,20 @@ function assertSupportedNodeVersion() {
|
||||
assertSupportedNodeVersion();
|
||||
ensureProfile();
|
||||
|
||||
console.log("[1/5] Installing Pi dependencies...");
|
||||
console.log("[1/6] Installing Pi dependencies...");
|
||||
run(["ci", "--ignore-scripts"], join(rootDir, "pi"));
|
||||
|
||||
console.log("[2/5] Hydrating model data and building local Pi packages...");
|
||||
console.log("[2/6] Hydrating model data and building local Pi packages...");
|
||||
hydrateModelData();
|
||||
run(["run", "build:offline"], join(rootDir, "pi"));
|
||||
|
||||
console.log("[3/5] Installing Pi Web with local Pi package links...");
|
||||
run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "pi-web"));
|
||||
console.log("[3/6] Installing Pi Web dependencies...");
|
||||
run(["ci", "--ignore-scripts"], join(rootDir, "pi-web"));
|
||||
|
||||
console.log("[4/5] Installing and validating managed Pi packages...");
|
||||
console.log("[4/6] Installing Pi Agent Server with local Pi package links...");
|
||||
run(["ci", "--ignore-scripts", "--install-links"], join(rootDir, "server"));
|
||||
|
||||
console.log("[5/6] Installing and validating managed Pi packages...");
|
||||
const packageResult = spawnSync(process.execPath, [join(rootDir, "scripts", "install-managed-packages.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
@@ -118,7 +121,7 @@ const profileResult = spawnSync(process.execPath, [join(rootDir, "scripts", "che
|
||||
if (profileResult.error) throw profileResult.error;
|
||||
if (profileResult.status !== 0) process.exit(profileResult.status ?? 1);
|
||||
|
||||
console.log("[5/5] Verifying the integration...");
|
||||
console.log("[6/6] Verifying the integration...");
|
||||
const checkResult = spawnSync(process.execPath, [join(rootDir, "scripts", "check-integration.mjs")], {
|
||||
cwd: rootDir,
|
||||
stdio: "inherit",
|
||||
|
||||
Reference in New Issue
Block a user