Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 8 additions & 6 deletions apps/backend/src/config/installed-apps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,24 @@ function uniqueExisting(paths: Array<string | null>): string[] {
return existing;
}

function resolveDevManifest(): string | null {
function resolveDevManifest(packagePath: string): string | null {
try {
const root = process.env.DEUS_REPO_ROOT ?? resolveRepoRoot(process.cwd());
return resolve(root, "packages/device-use/agentic-app.json");
return resolve(root, packagePath);
} catch {
return null;
}
}

function resolvePackagedManifest(): string | null {
function resolvePackagedManifest(relPath: string): string | null {
const resourcesPath = (process as { resourcesPath?: string }).resourcesPath;
if (!resourcesPath) return null;
return resolve(resourcesPath, "agentic-apps/device-use/agentic-app.json");
return resolve(resourcesPath, relPath);
}

export const INSTALLED_APP_MANIFESTS: readonly string[] = uniqueExisting([
resolvePackagedManifest(),
resolveDevManifest(),
resolvePackagedManifest("agentic-apps/device-use/agentic-app.json"),
resolveDevManifest("packages/device-use/agentic-app.json"),
resolvePackagedManifest("agentic-apps/pencil/agentic-app.json"),
resolveDevManifest("packages/pencil/agentic-app.json"),
]);
3 changes: 2 additions & 1 deletion apps/backend/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
reconcile as reconcileSimulators,
destroyAll as destroyAllSimulators,
} from "./services/simulator-context";
import { stopAllApps, sweepOrphanApps } from "./services/aap";
import { prefetchInstalledAppAssets, stopAllApps, sweepOrphanApps } from "./services/aap";
import { setApp } from "./services/route-delegate";
import { invalidate } from "./services/query-engine";
import {
Expand Down Expand Up @@ -46,6 +46,7 @@ const db = initDatabase();
// remembers their pids across restarts; we kill any still alive before
// accepting new commands so a re-launch allocates a fresh instance cleanly.
sweepOrphanApps();
prefetchInstalledAppAssets();

// Backfill git_origin_url for repos added before we tracked origin URLs.
// Runs once at startup (fire-and-forget) so WS subscribers get the data
Expand Down
116 changes: 113 additions & 3 deletions apps/backend/src/services/aap/apps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,16 @@
// 2. invalidate(["apps","running_apps"]) for WS subscribers
// 3. apps:launched q:event on successful ready (Phase 4 consumer)

import { spawnSync, type ChildProcess } from "node:child_process";
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
import { isAbsolute, resolve as resolvePath } from "node:path";

import type { Manifest } from "@shared/aap/manifest";
import { substituteTemplate, type TemplateVars } from "@shared/aap/template";
import {
substituteArgs,
substituteEnv,
substituteTemplate,
type TemplateVars,
} from "@shared/aap/template";
import type {
InstalledApp,
LaunchAppArgs,
Expand All @@ -37,7 +43,14 @@ import { invalidate } from "../query-engine";
import { broadcast } from "../ws.service";

import { allocateFreePort } from "./port-allocator";
import { isProcessAlive, killByPid, spawnApp, stopChild, waitForReady } from "./lifecycle";
import {
isProcessAlive,
killByPid,
resolveCommand,
spawnApp,
stopChild,
waitForReady,
} from "./lifecycle";
import { registerMcpForRunningApp, unregisterMcpForRunningApp } from "./mcp-bridge";
import { clearPids, readPids, recordPid } from "./pid-journal";
import {
Expand Down Expand Up @@ -92,6 +105,10 @@ const runningApps = new Map<string, RunningAppEntry>();
* in-flight promise — same result, one spawn. Cleared in a `finally`. */
const launching = new Map<string, Promise<LaunchAppResult>>();

/** App ids whose optional boot prefetch has already been started this backend
* process. Prefetch is best-effort and idempotent at the app layer. */
const prefetchStarted = new Set<string>();

// ----------------------------------------------------------------------------
// public read API
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -124,6 +141,90 @@ export function getRunningApps(workspaceId?: string | null): RunningApp[] {
return matches.map(toView);
}

// ----------------------------------------------------------------------------
// background prefetch
// ----------------------------------------------------------------------------

/** Start optional app-defined warmup commands without blocking backend boot.
* Used by heavy embedded apps (Pencil) to populate caches before the user opens
* the panel. Failures are logged and never affect app availability. */
export function prefetchInstalledAppAssets(): void {
for (const installed of loadInstalledApps()) {
const { manifest } = installed;
if (!manifest.prefetch || prefetchStarted.has(manifest.id)) continue;
if (!isPlatformCompatible(manifest)) continue;

prefetchStarted.add(manifest.id);
void runPrefetch(installed);
}
}

async function runPrefetch(installed: InstalledAppEntry): Promise<void> {
const { manifest, packageRoot } = installed;
const prefetch = manifest.prefetch;
if (!prefetch) return;

const vars: TemplateVars = {};
const rawCwd = prefetch.cwd ? substituteTemplate(prefetch.cwd, vars) : packageRoot;
const cwd = isAbsolute(rawCwd) ? rawCwd : resolvePath(packageRoot, rawCwd);
const command = resolveCommand(prefetch.command, packageRoot);
const args = substituteArgs(prefetch.args, vars);
const env: NodeJS.ProcessEnv = {
...process.env,
...substituteEnv(prefetch.env, vars),
DEUS_APP_ID: manifest.id,
DEUS_PREFETCH: "1",
};

await new Promise<void>((resolve) => {
let child: ChildProcess;
try {
child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
} catch (err) {
console.warn(`[AAP] Prefetch failed to spawn: ${manifest.id}`, {
error: getErrorMessage(err),
});
resolve();
return;
}
const stdout: string[] = [];
const stderr: string[] = [];
let finished = false;
const finish = (): void => {
if (finished) return;
finished = true;
resolve();
};
const push = (ring: string[], chunk: Buffer): void => {
ring.push(chunk.toString("utf8"));
while (ring.join("").length > 2_048) ring.shift();
};

child.stdout?.on("data", (chunk: Buffer) => push(stdout, chunk));
child.stderr?.on("data", (chunk: Buffer) => push(stderr, chunk));
child.once("error", (err) => {
console.warn(`[AAP] Prefetch failed to spawn: ${manifest.id}`, {
error: getErrorMessage(err),
});
finish();
});
child.once("exit", (code, signal) => {
if (finished) return;
if (code === 0) {
console.log(`[AAP] Prefetch complete: ${manifest.id}`);
} else {
console.warn(`[AAP] Prefetch failed: ${manifest.id}`, {
exitCode: code,
signal,
stderrTail: stderr.join("").slice(-1_024).trim() || undefined,
stdoutTail: stdout.join("").slice(-1_024).trim() || undefined,
});
}
finish();
});
});
}

// ----------------------------------------------------------------------------
// launch
// ----------------------------------------------------------------------------
Expand Down Expand Up @@ -460,6 +561,15 @@ function validateRequires(manifest: Manifest): void {
}
}

function isPlatformCompatible(manifest: Manifest): boolean {
for (const req of manifest.requires) {
if (req.type !== "platform") continue;
if (req.os && req.os !== process.platform) return false;
if (req.arch && req.arch !== process.arch) return false;
}
return true;
}

function isCliOnPath(name: string): boolean {
// `command -v` is POSIX and honours shell builtins + PATH lookup. On macOS
// it covers xcrun, git, node, bun, etc. without needing `which`.
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/services/aap/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export {
stopAllApps,
sweepOrphanApps,
readAppSkill,
prefetchInstalledAppAssets,
} from "./apps.service";

// Re-export the public view + contract types from shared so backend-internal
Expand Down
2 changes: 1 addition & 1 deletion apps/backend/src/services/aap/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ export async function stopChild(
*
* Returning absolute paths means Electron / Finder-launched backends don't
* depend on the inherited PATH to find workspace binaries. */
function resolveCommand(command: string, packageRoot: string): string {
export function resolveCommand(command: string, packageRoot: string): string {
if (isAbsolute(command)) return command;

// (1.5) Path-form command (`./dist/cli.js`, `bin/foo`, etc.) — Node's spawn
Expand Down
34 changes: 31 additions & 3 deletions apps/backend/test/integration/aap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// it, then asserts on the Map's observable state via getRunningApps.

import { spawn } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -43,6 +43,8 @@ vi.mock("../../src/services/agent", () => ({
// Register it as the only installed app by replacing the manifest list.
const fakeAppDir = mkdtempSync(join(tmpdir(), "aap-integration-"));
const fakeAppServer = join(fakeAppDir, "server.js");
const fakePrefetchScript = join(fakeAppDir, "prefetch.js");
const fakePrefetchMarker = join(fakeAppDir, "prefetched.txt");
writeFileSync(
fakeAppServer,
`
Expand All @@ -60,6 +62,14 @@ console.log("fake app on " + port);
`,
"utf8"
);
writeFileSync(
fakePrefetchScript,
`
const fs = require("node:fs");
fs.writeFileSync(process.argv[2], process.env.DEUS_APP_ID + ":" + process.env.DEUS_PREFETCH, "utf8");
`,
"utf8"
);

const fakeManifest = {
$schema: "https://agenticapps.dev/schema/v1.json",
Expand All @@ -74,6 +84,10 @@ const fakeManifest = {
env: {},
ready: { type: "http", path: "/health", timeoutMs: 10_000 },
},
prefetch: {
command: "bun",
args: ["run", fakePrefetchScript, fakePrefetchMarker],
},
ui: { url: "http://127.0.0.1:{port}/" },
agent: { tools: { type: "mcp-http", url: "http://127.0.0.1:{port}/mcp" } },
storage: {},
Expand All @@ -83,9 +97,12 @@ const fakeManifest = {
const fakeManifestPath = join(fakeAppDir, "agentic-app.json");
writeFileSync(fakeManifestPath, JSON.stringify(fakeManifest, null, 2), "utf8");

const fakeManifestWithoutPrefetch = { ...fakeManifest };
delete (fakeManifestWithoutPrefetch as { prefetch?: unknown }).prefetch;

// Second manifest for the ENOENT test — a command that doesn't exist on PATH.
const bogusManifest = {
...fakeManifest,
...fakeManifestWithoutPrefetch,
id: "test.bogus-command",
launch: { ...fakeManifest.launch, command: "this-binary-does-not-exist-xyz123" },
};
Expand All @@ -95,7 +112,7 @@ writeFileSync(bogusManifestPath, JSON.stringify(bogusManifest, null, 2), "utf8")
// Third manifest for the cli-requires test — declares a missing CLI with an
// install hint. Validated BEFORE spawn so the hint reaches the user.
const needsCliManifest = {
...fakeManifest,
...fakeManifestWithoutPrefetch,
id: "test.needs-missing-cli",
requires: [
{
Expand Down Expand Up @@ -125,6 +142,7 @@ const {
stopApp,
stopAppsForWorkspace,
stopAllApps,
prefetchInstalledAppAssets,
sweepOrphanApps,
} = await import("../../src/services/aap");
const { __clearRegistryCacheForTests } = await import("../../src/services/aap/registry");
Expand Down Expand Up @@ -173,6 +191,16 @@ describe("aap/apps.service (integration, in-memory)", () => {
]);
});

it("runs app prefetch commands in the background", async () => {
rmSync(fakePrefetchMarker, { force: true });
prefetchInstalledAppAssets();
await waitForCondition(
() => existsSync(fakePrefetchMarker),
(exists) => exists
);
expect(readFileSync(fakePrefetchMarker, "utf8")).toBe("test.fake-app:1");
});

it("launches, becomes ready, and is reachable on /health", async () => {
const result = await launchApp({
appId: "test.fake-app",
Expand Down
16 changes: 16 additions & 0 deletions apps/backend/test/unit/shared/aap/manifest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,22 @@ describe("shared/aap/manifest", () => {
expect(tcp.launch.ready.type).toBe("tcp");
});

it("parses optional prefetch commands", () => {
const manifest = parseManifest({
...VALID_MANIFEST,
prefetch: {
command: "node",
args: ["./dist/serve.js", "--prefetch"],
env: { WARM_CACHE: "1" },
},
});
expect(manifest.prefetch).toEqual({
command: "node",
args: ["./dist/serve.js", "--prefetch"],
env: { WARM_CACHE: "1" },
});
});

it("rejects unsupported ready probe types", () => {
const stdout = safeParseManifest({
...VALID_MANIFEST,
Expand Down
Loading
Loading