Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
2 changes: 1 addition & 1 deletion apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ Older `ade runtime ...` and `ade sync pin ...` command aliases remain available

## Project registry

The ADE brain owns a per-machine project catalog at `$ADE_HOME/projects.json` (`ProjectRegistry` in `src/services/projects/projectRegistry.ts`). A project record carries a stable `projectId` (`project_<sha256(rootPath)[..24]>`), root path, display name, `addedAt`, `lastOpenedAt`, and the resolved git origin URL.
The ADE brain owns a per-machine project catalog at `$ADE_HOME/projects.json` (`ProjectRegistry` in `src/services/projects/projectRegistry.ts`). A project record carries a stable `projectId` (`project_<sha256(rootPath)[..24]>`), root path, display name, `addedAt`, `lastOpenedAt`, and the resolved git origin URL. Each record also has a `catalogVisibility` (`recent` vs `system`) and a `registrationSource`: explicit commands (`ade projects add`, `ade init`) register as `recent`/`cli-explicit`, while runtime auto-registration (an agent attaching to run a project-scoped command) registers as `system`/`runtime-auto` so it does not surface as a recent project. `ade projects list --text` shows a `visibility` column so all rows, including `system` ones, are visible to the operator.

Manage the registry through typed CLI commands:

Expand Down
15 changes: 12 additions & 3 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1516,8 +1516,8 @@ export async function createAdeRuntime(args: {
// ONE tunnel client per machine (keyed by the config file): per-scope
// instances would re-register the same machineKey with the relay on every
// project open and churn the connection paired phones dial through.
const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () =>
createSyncTunnelClientService({
const syncTunnelClientService = getSharedSyncTunnelClientService(cloudRelayFilePath, () => {
const service = createSyncTunnelClientService({
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
Expand All @@ -1543,7 +1543,16 @@ export async function createAdeRuntime(args: {
return null;
}
},
}));
});
resolvedArgs.syncRuntime?.sharedSyncListener?.onLoopbackValidated(() => {
void service.validateCurrentBridge().catch((error) => {
logger.warn("sync.tunnel_bridge_validation_failed", {
error: error instanceof Error ? error.message : String(error),
});
});
});
return service;
});
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
// host socket per machineKey (last wins), so a headless one-shot CLI
Expand Down
39 changes: 38 additions & 1 deletion apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
applySyncWebPairingFlags,
automaticProjectRegistrationParams,
buildAdeCodeArgs,
buildCliPlan,
checkLinearReadiness,
Expand All @@ -15,6 +16,7 @@ import {
formatOutput,
graphWaitState,
inferFormatter,
includeHostProjectInCatalog,
isEphemeralRuntimeSocketPath,
isFailedServiceManagerResult,
machineRuntimeMismatchReason,
Expand Down Expand Up @@ -130,6 +132,32 @@ function writeSyncHostSingletonLock(args: {
}

describe("ADE CLI", () => {
it("includes the system host in the mobile catalog without exposing other system projects", () => {
const projects = [
{ projectId: "project_recent", catalogVisibility: "recent" as const },
{ projectId: "project_system_host", catalogVisibility: "system" as const },
{ projectId: "project_system_other", catalogVisibility: "system" as const },
];
const recentProjects = projects.filter(
(project) => project.catalogVisibility === "recent",
);
const hostProject = projects.find(
(project) => project.projectId === "project_system_host",
)!;

expect(
includeHostProjectInCatalog(recentProjects, hostProject).map(
(project) => project.projectId,
),
).toEqual(["project_recent", "project_system_host"]);
expect(
includeHostProjectInCatalog(
[...recentProjects, hostProject],
hostProject,
).map((project) => project.projectId),
).toEqual(["project_recent", "project_system_host"]);
});

it("builds projectless account commands and reports the signed-out local-first message", () => {
const statusPlan = expectExecutePlan(buildCliPlan(["auth", "status"]));
expect(statusPlan).toMatchObject({
Expand Down Expand Up @@ -773,7 +801,11 @@ describe("ADE CLI", () => {
{
key: "result",
method: "projects.add",
params: { rootPath: "/tmp/project" },
params: {
rootPath: "/tmp/project",
catalogVisibility: "recent",
registrationSource: "cli-explicit",
},
},
],
});
Expand Down Expand Up @@ -825,6 +857,11 @@ describe("ADE CLI", () => {
expect(lanes.kind).toBe("execute");
if (lanes.kind !== "execute") return;
expect(shouldAutoRegisterProjectForPlan(lanes)).toBe(true);
expect(automaticProjectRegistrationParams("/tmp/project")).toEqual({
rootPath: "/tmp/project",
catalogVisibility: "system",
registrationSource: "runtime-auto",
});
});

it("builds sync status and pairing PIN commands", () => {
Expand Down
89 changes: 75 additions & 14 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13235,6 +13235,18 @@ export function shouldAutoRegisterProjectForPlan(
return plan.steps.some((step) => !isMachineRuntimeScopedMethod(step.method));
}

export function automaticProjectRegistrationParams(rootPath: string): {
rootPath: string;
catalogVisibility: "system";
registrationSource: "runtime-auto";
} {
return {
rootPath,
catalogVisibility: "system",
registrationSource: "runtime-auto",
};
}

function buildMachinesPlan(args: string[]): CliPlan {
const sub = firstPositional(args) ?? "list";
if (sub === "list" || sub === "ls") {
Expand Down Expand Up @@ -13493,7 +13505,15 @@ function buildProjectsPlan(args: string[]): CliPlan {
kind: "execute",
label: "projects add",
formatter: "projects-list",
steps: [{ key: "result", method: "projects.add", params: { rootPath } }],
steps: [{
key: "result",
method: "projects.add",
params: {
rootPath,
catalogVisibility: "recent",
registrationSource: "cli-explicit",
},
}],
};
}
if (sub === "remove" || sub === "rm" || sub === "delete") {
Expand Down Expand Up @@ -13592,9 +13612,10 @@ async function createConnection(
close: () => socketClient?.close(),
};
if (autoRegisterProject) {
const registered = await connection.request("projects.add", {
rootPath: roots.projectRoot,
});
const registered = await connection.request(
"projects.add",
automaticProjectRegistrationParams(roots.projectRoot),
);
const registeredProjectId = isRecord(registered)
? asString(registered.projectId)
: null;
Expand Down Expand Up @@ -14693,6 +14714,19 @@ async function runNativeRpcStdio(options: GlobalOptions): Promise<void> {
}
}

export function includeHostProjectInCatalog<T extends { projectId: string }>(
recentProjects: T[],
hostProject: T | null,
): T[] {
if (
!hostProject ||
recentProjects.some((project) => project.projectId === hostProject.projectId)
) {
return recentProjects;
}
return [...recentProjects, hostProject];
}

async function runServe(
rest: string[],
options: GlobalOptions,
Expand Down Expand Up @@ -14766,6 +14800,10 @@ async function runServe(
try {
preferredSyncProjectId = projectRegistry.add(
path.resolve(preferredSyncProjectRoot),
{
catalogVisibility: "system",
registrationSource: "runtime-auto",
},
).projectId;
} catch (error) {
process.stderr.write(
Expand Down Expand Up @@ -14856,7 +14894,10 @@ async function runServe(
const registerHeadlessMobileProject = async (
rootPath: string,
): Promise<SyncMobileProjectSummary> => {
const record = projectRegistry.add(rootPath);
const record = projectRegistry.add(rootPath, {
catalogVisibility: "recent",
registrationSource: "mobile",
});
return await mobileProjectSummaryForHeadlessRecord(record);
};
const openHeadlessMobileProject = async (
Expand Down Expand Up @@ -14949,8 +14990,12 @@ async function runServe(
};
const machineProjectCatalogProvider: SyncProjectCatalogProvider = {
listProjects: async () => ({
projects: projectRegistry
.list()
projects: includeHostProjectInCatalog(
projectRegistry.listRecent(),
preferredSyncProjectId
? projectRegistry.get(preferredSyncProjectId)
: null,
)
.map((record) =>
toMobileProjectSummary(record, {
isAvailable: fs.existsSync(record.rootPath),
Expand Down Expand Up @@ -15159,15 +15204,27 @@ async function runServe(
});
clearSyncRuntimeRpcHandlerFactory = setSyncRuntimeRpcHandlerFactory(createHandler);
const startSyncHost = async () => {
let activeScope: Awaited<
ReturnType<InstanceType<typeof ProjectScopeRegistry>["resolveActiveSyncHost"]>
>;
if (preferredSyncProjectId) {
return await scopeRegistry.switchSyncHost(preferredSyncProjectId);
activeScope = await scopeRegistry.switchSyncHost(preferredSyncProjectId);
} else {
activeScope = await scopeRegistry.resolveActiveSyncHost();
}
const activeScope = await scopeRegistry.resolveActiveSyncHost();
if (activeScope) return activeScope;
if (sharedSyncListener) {
if (!activeScope && sharedSyncListener) {
await sharedSyncListener.ensureListening([DEFAULT_SYNC_HOST_PORT]);
}
return null;
if (activeScope) {
const prewarmTimer = setImmediate(() => {
void scopeRegistry.prewarmRecentScopes({
excludeProjectId: activeScope?.registryProjectId,
limit: 2,
});
});
prewarmTimer.unref?.();
}
return activeScope ?? null;
};
const disposeServeResources = async () => {
accountMachinePublisher?.dispose();
Expand Down Expand Up @@ -15511,7 +15568,10 @@ async function runInit(
]);
const layout = resolveMachineAdeLayout();
const registry = new ProjectRegistry(layout);
const project = registry.add(path.resolve(targetPath ?? process.cwd()));
const project = registry.add(path.resolve(targetPath ?? process.cwd()), {
catalogVisibility: "recent",
registrationSource: "cli-explicit",
});
return {
project,
registryPath: registry.path,
Expand Down Expand Up @@ -17253,11 +17313,12 @@ function formatProjectsList(value: unknown): string {
? [value]
: firstArray(value, ["projects", "items"]);
return renderTable(
["project", "name", "path", "git origin", "last opened"],
["project", "name", "path", "visibility", "git origin", "last opened"],
projects.map((project) => [
project.projectId,
project.displayName,
project.rootPath,
project.catalogVisibility === "system" ? "system" : "recent",
project.gitOriginUrl,
typeof project.lastOpenedAt === "number" && project.lastOpenedAt > 0
? new Date(project.lastOpenedAt).toISOString()
Expand Down
8 changes: 7 additions & 1 deletion apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -557,12 +557,18 @@ describe("multi-project RPC server", () => {
jsonrpc: "2.0",
id: 2,
method: "projects.add",
params: { rootPath: projectRoot },
params: {
rootPath: projectRoot,
catalogVisibility: "system",
registrationSource: "test",
},
});
expect(added).toMatchObject({
rootPath: expectedProjectRoot,
displayName: "project",
gitOriginUrl: null,
catalogVisibility: "system",
registrationSource: "test",
});

const listed = await handler({
Expand Down
58 changes: 55 additions & 3 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,10 @@ import { resolveMachineAdeLayout } from "./services/projects/machineLayout";
import { resolveRemoteProjectIcon } from "./services/projects/projectIconResolver";
import {
ProjectRegistry,
SYSTEM_PROJECT_REGISTRATION,
type ProjectId,
type ProjectRegistrationIntent,
type ProjectRegistrationSource,
} from "./services/projects/projectRegistry";
import { ProjectScopeRegistry } from "./services/projects/projectScope";
import { PersonalChatScope } from "./services/personalChats/personalChatScope";
Expand Down Expand Up @@ -93,6 +96,7 @@ const RUNTIME_METHODS = new Set([
"machineInfo.get",
"projects.list",
"projects.add",
"projects.setCatalogVisibility",
"projects.remove",
"projects.touch",
"projects.browseDirectories",
Expand Down Expand Up @@ -420,6 +424,24 @@ function readProjectId(params: Record<string, unknown>): ProjectId | null {
: null;
}

function readProjectRegistrationIntent(
params: Record<string, unknown>,
): ProjectRegistrationIntent {
const catalogVisibility =
params.catalogVisibility === "recent" || params.catalogVisibility === "system"
? params.catalogVisibility
: SYSTEM_PROJECT_REGISTRATION.catalogVisibility;
const registrationSource: ProjectRegistrationSource =
params.registrationSource === "desktop" ||
params.registrationSource === "mobile" ||
params.registrationSource === "cli-explicit" ||
params.registrationSource === "runtime-auto" ||
params.registrationSource === "test"
? params.registrationSource
: SYSTEM_PROJECT_REGISTRATION.registrationSource;
return { catalogVisibility, registrationSource };
}

function omitProjectId(
params: Record<string, unknown>,
): Record<string, unknown> {
Expand Down Expand Up @@ -870,7 +892,27 @@ export function createMultiProjectRpcRequestHandler(
"projects.add requires rootPath.",
);
}
return decorateProjectWithIcon(projectRegistry.add(rootPath));
return decorateProjectWithIcon(
projectRegistry.add(rootPath, readProjectRegistrationIntent(params)),
);
}

if (method === "projects.setCatalogVisibility") {
const rootPath =
typeof params.rootPath === "string" ? params.rootPath.trim() : "";
if (!rootPath) {
throw new JsonRpcError(
JsonRpcErrorCode.invalidParams,
"projects.setCatalogVisibility requires rootPath.",
);
}
const registration = readProjectRegistrationIntent(params);
const project = projectRegistry.setCatalogVisibilityByRootPath(
rootPath,
registration.catalogVisibility,
registration.registrationSource,
);
return project ? decorateProjectWithIcon(project) : null;
}

if (method === "projects.remove") {
Expand Down Expand Up @@ -950,15 +992,25 @@ export function createMultiProjectRpcRequestHandler(
await createMachineProjectScaffoldService().createLocalProject(
readCreateProjectInput(params),
);
return decorateProjectWithIcon(projectRegistry.add(result.rootPath));
return decorateProjectWithIcon(
projectRegistry.add(
result.rootPath,
readProjectRegistrationIntent(params),
),
);
}

if (method === "projects.clone") {
const result =
await createMachineProjectScaffoldService().cloneRepository(
readCloneProjectInput(params),
);
return decorateProjectWithIcon(projectRegistry.add(result.rootPath));
return decorateProjectWithIcon(
projectRegistry.add(
result.rootPath,
readProjectRegistrationIntent(params),
),
);
}

if (method === "projects.listMyGitHubRepos") {
Expand Down
Loading