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
3 changes: 3 additions & 0 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export type AdeRuntimeSyncOptions = {
localDeviceIdPath?: string;
phonePairingStateDir?: string;
projectCatalogProvider?: Parameters<typeof createSyncService>[0]["projectCatalogProvider"];
rosterProvider?: Parameters<typeof createSyncService>[0]["rosterProvider"];
remoteCommandExecutor?: Parameters<typeof createSyncService>[0]["remoteCommandExecutor"];
/**
* Brain-level websocket listener shared by every project scope's sync host
Expand Down Expand Up @@ -1078,6 +1079,7 @@ export async function createAdeRuntime(args: {
automationService,
prService: headlessLinearServices.prService,
secretService: automationSecretService,
githubService: headlessLinearServices.githubService,
listRules: () => projectConfigService.get().effective.automations ?? [],
})
: null;
Expand Down Expand Up @@ -1202,6 +1204,7 @@ export async function createAdeRuntime(args: {
hostDiscoveryEnabled: resolvedArgs.syncRuntime.hostDiscoveryEnabled ?? true,
forceHostRole: resolvedArgs.syncRuntime.forceHostRole ?? false,
projectCatalogProvider: resolvedArgs.syncRuntime.projectCatalogProvider,
rosterProvider: resolvedArgs.syncRuntime.rosterProvider,
remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor,
getModelPickerStore: () => getSharedModelPickerStore(db),
onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }),
Expand Down
15 changes: 15 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12833,6 +12833,7 @@ async function runServe(
{ createSharedSyncListener },
{ resolveMobileProjectIconDataUrl },
{ createBrainProjectActionsSyncHandler },
{ buildRosterSnapshot },
] = await Promise.all([
import("./services/projects/machineLayout"),
import("./services/projects/projectRegistry"),
Expand All @@ -12841,6 +12842,7 @@ async function runServe(
import("./services/sync/sharedSyncListener"),
import("../../desktop/src/main/services/projects/projectIconThumbnail"),
import("./services/sync/brainProjectActionsSyncHandler"),
import("./services/sync/rosterBuilder"),
]);

const layout = resolveMachineAdeLayout();
Expand Down Expand Up @@ -13198,6 +13200,19 @@ async function runServe(
localDeviceIdPath: path.join(layout.secretsDir, "sync-device-id"),
phonePairingStateDir: layout.secretsDir,
projectCatalogProvider: machineProjectCatalogProvider,
// All-projects chat roster (mobile hub). Closes over `scopeRegistry`,
// which is assigned by this very `new ProjectScopeRegistry(...)` call —
// safe because `buildSnapshot` only runs later (on `roster_subscribe`),
// by which point the binding is set (mirrors machineProjectCatalogProvider).
rosterProvider: {
buildSnapshot: () =>
buildRosterSnapshot({
projectRegistry,
scopeRegistry,
hostProjectId: preferredSyncProjectId,
logger: headlessProjectLogger,
}),
},
},
});
const previousRole = process.env.ADE_DEFAULT_ROLE;
Expand Down
2 changes: 2 additions & 0 deletions apps/ade-cli/src/headlessLinearServices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1009,6 +1009,8 @@ export function createHeadlessGitHubService(
return fetchGitHubAppInstallationStatus({
repo,
secretReader: options.githubRelaySecretReader,
forceRefresh: args.forceRefresh === true,
githubToken: getToken(),
});
},
async getRepoOrThrow() {
Expand Down
40 changes: 40 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import fs from "node:fs";
import { createHash } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -48,6 +49,45 @@ function makeRuntime(label: string) {
}

describe("multi-project RPC server", () => {
it("reports a build hash for manually-started CLI entrypoints", async () => {
const { registry, root } = createRegistry();
const cliPath = path.join(root, "manual-cli.cjs");
fs.writeFileSync(cliPath, "console.log('manual runtime');\n");
const expectedHash = createHash("sha256").update(fs.readFileSync(cliPath)).digest("hex");
const originalArgv = process.argv;
const originalBuildHash = process.env.ADE_RUNTIME_BUILD_HASH;
process.argv = [originalArgv[0] ?? "node", cliPath];
delete process.env.ADE_RUNTIME_BUILD_HASH;
try {
const handler = createMultiProjectRpcRequestHandler({
serverVersion: "test",
projectRegistry: registry,
});

const init = await handler({
jsonrpc: "2.0",
id: 1,
method: "ade/initialize",
params: {},
});

expect(init).toMatchObject({
runtimeInfo: {
buildHash: expectedHash,
multiProject: true,
},
});
handler.dispose();
} finally {
process.argv = originalArgv;
if (originalBuildHash === undefined) {
delete process.env.ADE_RUNTIME_BUILD_HASH;
} else {
process.env.ADE_RUNTIME_BUILD_HASH = originalBuildHash;
}
}
});

it("exposes runtime-scoped project registry methods", async () => {
const { projectRoot, expectedProjectRoot, registry } = createRegistry();
const handler = createMultiProjectRpcRequestHandler({
Expand Down
33 changes: 32 additions & 1 deletion apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { createAdeRpcRequestHandler } from "./adeRpcServer";
import { createHash } from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { browseProjectDirectories } from "../../desktop/src/main/services/projects/projectBrowserService";
Expand Down Expand Up @@ -282,6 +284,11 @@ function readLimit(value: unknown): number {
: 100;
}

// The entrypoint cannot change during the process lifetime, so hash it once and
// reuse the result. `undefined` means "not computed yet"; `null` is a cached
// failure (missing/unreadable entrypoint) that must not retry on every call.
let cachedRuntimeBuildHash: string | null | undefined;

export function createMultiProjectRpcRequestHandler(
options: MultiProjectRpcHandlerOptions,
): JsonRpcHandler & {
Expand Down Expand Up @@ -450,11 +457,35 @@ export function createMultiProjectRpcRequestHandler(
return typeof value === "string" && value.trim() ? value.trim() : null;
};

const computeRuntimeBuildHash = (): string | null => {
if (cachedRuntimeBuildHash !== undefined) return cachedRuntimeBuildHash;
const entrypoint = process.argv[1];
if (typeof entrypoint !== "string" || !entrypoint.trim()) {
cachedRuntimeBuildHash = null;
return null;
}
try {
const resolved = path.resolve(entrypoint);
const stat = fs.statSync(resolved);
if (!stat.isFile()) {
cachedRuntimeBuildHash = null;
return null;
}
cachedRuntimeBuildHash = createHash("sha256")
.update(fs.readFileSync(resolved))
.digest("hex");
return cachedRuntimeBuildHash;
} catch {
cachedRuntimeBuildHash = null;
return null;
}
};

const resolveRuntimeEnvInfo = () => {
const projectRoot = trimmedEnvOrNull("ADE_PROJECT_ROOT");
const packageChannel = trimmedEnvOrNull("ADE_PACKAGE_CHANNEL");
return {
buildHash: trimmedEnvOrNull("ADE_RUNTIME_BUILD_HASH"),
buildHash: trimmedEnvOrNull("ADE_RUNTIME_BUILD_HASH") ?? computeRuntimeBuildHash(),
defaultRole: normalizeAdeRuntimeRole(process.env.ADE_DEFAULT_ROLE),
packageChannel,
projectRoot: projectRoot ? path.resolve(projectRoot) : null,
Expand Down
11 changes: 11 additions & 0 deletions apps/ade-cli/src/services/projects/projectScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,17 @@ export class ProjectScopeRegistry {
};
}

/**
* Non-booting lookup of an already-booted (or currently-booting) scope.
* Returns the cached scope promise when one exists, or `null` when the
* project has never been activated. Unlike `get()` this NEVER boots a scope,
* so the all-projects roster can overlay live fidelity onto the projects that
* happen to be running without spinning up a runtime for every project.
*/
getIfBooted(projectId: ProjectId): Promise<ProjectScope> | null {
return this.scopes.get(projectId) ?? null;
}

async get(projectId: ProjectId): Promise<ProjectScope> {
const cached = this.scopes.get(projectId);
if (cached) return await cached;
Expand Down
Loading
Loading