Skip to content
Merged
68 changes: 51 additions & 17 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { augmentProcessPathWithShellAndKnownCliDirs, setPathEnvValue } from "../
import { createAgentChatService } from "../../desktop/src/main/services/chat/agentChatService";
import { createOrchestrationService } from "../../desktop/src/main/services/orchestration/orchestrationService";
import type { createPrService } from "../../desktop/src/main/services/prs/prService";
import { createPrPollingService } from "../../desktop/src/main/services/prs/prPollingService";
import { createPrSummaryService } from "../../desktop/src/main/services/prs/prSummaryService";
import { createQueueLandingService } from "../../desktop/src/main/services/prs/queueLandingService";
import { createCtoStateService } from "../../desktop/src/main/services/cto/ctoStateService";
Expand All @@ -70,7 +71,7 @@ import type { createSyncService } from "./services/sync/syncService";
import type { SharedSyncListener } from "./services/sync/sharedSyncListener";
import type { createSyncHostService, SyncRuntimeKind } from "./services/sync/syncHostService";
import { getSharedModelPickerStore } from "./services/modelPickerStore";
import { createAutomationIngressService } from "../../desktop/src/main/services/automations/automationIngressService";
import { createAutomationIngressService, createKvIngressCursorStore } from "../../desktop/src/main/services/automations/automationIngressService";
import { createAutomationSecretService } from "../../desktop/src/main/services/automations/automationSecretService";
import { createProjectSecretService } from "../../desktop/src/main/services/secrets/projectSecretService";
import type { createGithubService } from "../../desktop/src/main/services/github/githubService";
Expand Down Expand Up @@ -1067,22 +1068,28 @@ export async function createAdeRuntime(args: {
})
: null;
automationServiceRef = automationService;
const automationSecretService = automationFeatureEnabled
? createAutomationSecretService({
adeDir: paths.adeDir,
logger,
})
: null;
const automationIngressService = automationFeatureEnabled && automationService && automationSecretService
? createAutomationIngressService({
logger,
automationService,
prService: headlessLinearServices.prService,
secretService: automationSecretService,
githubService: headlessLinearServices.githubService,
listRules: () => projectConfigService.get().effective.automations ?? [],
})
: null;
const automationSecretService = createAutomationSecretService({
adeDir: paths.adeDir,
logger,
});
// The ingress runs even when the automations feature is unavailable: its
// GitHub relay poll feeds prService.ingestGithubWebhook, which is how
// webhook-driven PR state updates reach installed (non-source) runtimes.
// Automation rule dispatch stays gated on automationService being present.
const automationIngressService = createAutomationIngressService({
logger,
automationService,
prService: headlessLinearServices.prService,
secretService: automationSecretService,
githubService: headlessLinearServices.githubService,
listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []),
ingressCursorStore: createKvIngressCursorStore(db),
});
void automationIngressService.start().catch((error) => {
logger.warn("automations.ingress_start_failed", {
error: error instanceof Error ? error.message : String(error),
});
});
const configReloadService = createConfigReloadService({
paths: {
sharedPath: adeProjectService.paths.sharedConfigPath,
Expand Down Expand Up @@ -1148,6 +1155,32 @@ export async function createAdeRuntime(args: {
aiIntegrationService,
});

// GitHub polling fallback. Runtime-bound desktop windows route PR reads to
// this daemon instead of the desktop main process, so the daemon must own
// the background polling loop that emits `prs-updated` — otherwise PR state
// only refreshes when a surface happens to issue a direct read.
const prPollingService = createPrPollingService({
logger,
prService: headlessLinearServices.prService,
projectConfigService,
db,
onEvent: emitPrEvent,
onPullRequestsChanged: async ({ changedPrs, changes }) => {
if (changedPrs.length > 0) {
headlessLinearServices.prService.markHotRefresh(changedPrs.map((pr) => pr.id));
}
for (const { pr, previousState, previousChecksStatus, previousReviewStatus } of changes) {
automationService?.onPullRequestChanged?.({
pr,
previousState,
previousChecksStatus,
previousReviewStatus,
});
}
},
});
prPollingService.start();

const usageTrackingService = createUsageTrackingService({
logger,
pollIntervalMs: 120_000,
Expand Down Expand Up @@ -1304,6 +1337,7 @@ export async function createAdeRuntime(args: {
clearTimeout(staleSessionReconcileTimer);
}
void configReloadService.dispose().catch(() => {});
swallow(() => prPollingService.dispose());
swallow(() => automationIngressService?.dispose());
swallow(() => automationService?.dispose());
swallow(() => usageTrackingService.dispose());
Expand Down
41 changes: 29 additions & 12 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron";

Check warning on line 1 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'shell' is defined but never used. Allowed unused vars must match /^_/u
import { AsyncLocalStorage } from "node:async_hooks";
import os from "node:os";
import path from "node:path";
Expand Down Expand Up @@ -146,7 +146,7 @@
import { createAutomationPlannerService } from "./services/automations/automationPlannerService";
import { createAutomationSecretService } from "./services/automations/automationSecretService";
import { createProjectSecretService } from "./services/secrets/projectSecretService";
import { createAutomationIngressService } from "./services/automations/automationIngressService";
import { createAutomationIngressService, createKvIngressCursorStore } from "./services/automations/automationIngressService";
import { createReviewService } from "./services/review/reviewService";
import { createGithubPollingService } from "./services/automations/githubPollingService";
import type { AutomationAdeActionRegistry } from "./services/automations/automationService";
Expand Down Expand Up @@ -351,14 +351,16 @@
process.env.ADE_STABILITY_MODE === "1" || !!process.env.VITE_DEV_SERVER_URL;
const enableAllBackgroundTasks =
process.env.ADE_ENABLE_ALL_BACKGROUND_TASKS === "1";
// In dev stability mode, only enable essential background tasks by default.
// In startup stability mode, only enable essential background tasks by default.
// Use ADE_ENABLE_ALL_BACKGROUND_TASKS=1 or individual flags to enable others.
const defaultEnabledBackgroundTaskFlags = new Set<string>([
"ADE_ENABLE_CONFIG_RELOAD",
"ADE_ENABLE_USAGE_TRACKING",
"ADE_ENABLE_HEAD_WATCHER",
"ADE_ENABLE_PORT_ALLOCATION_RECOVERY",
"ADE_ENABLE_PR_POLLING",
"ADE_ENABLE_SYNC_INIT",
"ADE_ENABLE_AUTOMATION_INGRESS",
]);

function readString(source: Record<string, unknown> | null | undefined, key: string): string | undefined {
Expand Down Expand Up @@ -3187,16 +3189,18 @@
prService,
onEvent: (event) => emitProjectEvent(projectRoot, IPC.reviewEvent, event),
});
const automationIngressService = automationService
? createAutomationIngressService({
logger,
automationService,
prService,
secretService: automationSecretService,
githubService,
listRules: () => projectConfigService.get().effective.automations ?? [],
})
: null;
// Constructed even when automations are unavailable (packaged builds):
// the relay poll feeds prService.ingestGithubWebhook for PR freshness,
// while automation rule dispatch stays gated on automationService.
const automationIngressService = createAutomationIngressService({
logger,
automationService: automationService ?? null,
prService,
secretService: automationSecretService,
githubService,
listRules: () => (automationService ? projectConfigService.get().effective.automations ?? [] : []),
ingressCursorStore: createKvIngressCursorStore(db),
});

const githubPollingService = automationService
? createGithubPollingService({
Expand Down Expand Up @@ -3752,6 +3756,19 @@
projectConfigService,
usageTrackingService,
});

scheduleBackgroundProjectTask(
"prs.polling_start",
() => prPollingService.start(),
(error) => {
logger.warn("prs.polling_start_failed", {
error: error instanceof Error ? error.message : String(error),
});
},
0,
"ADE_ENABLE_PR_POLLING",
);

if (automationIngressService) {
scheduleBackgroundProjectTask(
"automations.ingress_start",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,134 @@ describe("automationIngressService", () => {
}));
});

it("still ingests relay PR webhooks when the automations feature is unavailable", async () => {
const cursors = new Map<string, string | null>();
const ingestGithubWebhook = vi.fn(async () => ({
processed: true,
duplicate: false,
repoOwner: "arul28",
repoName: "ADE",
githubPrNumber: 687,
linkedPrIds: [],
reason: null,
}));
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify({
events: [
{
cursor: "seq:3",
eventId: "delivery-3",
githubEvent: "pull_request",
summary: "GitHub pull_request · closed · arul28/ADE · #687",
createdAt: receivedAt,
payload: {
action: "closed",
repository: { full_name: "arul28/ADE" },
pull_request: { number: 687, title: "Github Auth Checks Failed", merged: true },
},
},
],
nextCursor: "seq:3",
}), { headers: { "content-type": "application/json" } }));

service = createAutomationIngressService({
logger: makeLogger() as never,
automationService: null,
prService: { ingestGithubWebhook } as never,
secretService: {
getSecret: () => null,
} as never,
githubService: {
detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })),
getAppUserTokenForRelay: vi.fn(async () => "ghu_app_user_token"),
},
listRules: () => [],
ingressCursorStore: {
get: (source) => cursors.get(source) ?? null,
set: ({ source, cursor }) => {
cursors.set(source, cursor);
},
},
});

// start() must not bind the local automation webhook server in this mode.
await service.start();
expect(fetchSpy).toHaveBeenCalledWith(
"https://ade-github-webhook-relay.arulsharma1028.workers.dev/github/repos/arul28/ADE/events",
expect.objectContaining({
headers: expect.objectContaining({
authorization: "Bearer ghu_app_user_token",
}),
}),
);
expect(ingestGithubWebhook).toHaveBeenCalledWith(expect.objectContaining({
eventName: "pull_request",
deliveryId: "delivery-3",
payload: expect.objectContaining({
pull_request: expect.objectContaining({ number: 687 }),
}),
}));
expect(cursors.get("github-relay")).toBe("seq:3");
expect(service.getStatus()).toBeNull();
expect(service.listRecentEvents()).toEqual([]);
});

it("refuses construction without cursor persistence when automations are unavailable", () => {
expect(() => createAutomationIngressService({
logger: makeLogger() as never,
automationService: null,
prService: { ingestGithubWebhook: vi.fn() } as never,
secretService: { getSecret: () => null } as never,
listRules: () => [],
})).toThrowError(/ingressCursorStore/);
});

it("treats missing GitHub App authorization as quiet auth-pending, not a per-tick error", async () => {
const logger = makeLogger();
const fetchSpy = vi.spyOn(globalThis, "fetch");
const getAppUserTokenForRelay = vi.fn(async () => {
throw new Error("Authorize the ADE GitHub App with GitHub before using the hosted relay.");
});

service = createAutomationIngressService({
logger: logger as never,
automationService: null,
prService: { ingestGithubWebhook: vi.fn() } as never,
secretService: {
getSecret: () => null,
} as never,
githubService: {
detectRepo: vi.fn(async () => ({ owner: "arul28", name: "ADE" })),
getAppUserTokenForRelay,
},
listRules: () => [],
ingressCursorStore: {
get: () => null,
set: () => {},
},
});

await service.start();
expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith("automations.github_relay_auth_pending", expect.objectContaining({
error: expect.stringContaining("Authorize the ADE GitHub App"),
}));
expect(logger.warn).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();

// Scheduled re-entry inside the cooldown window skips the token attempt.
await service.start();
expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(1);

// Explicit pollNow (e.g. right after authorizing) bypasses the cooldown
// and the transition log fires only once.
await service.pollNow();
expect(getAppUserTokenForRelay).toHaveBeenCalledTimes(2);
const authPendingLogs = (logger.info.mock.calls as unknown[][])
.filter((call) => call[0] === "automations.github_relay_auth_pending");
expect(authPendingLogs).toHaveLength(1);
expect(logger.warn).not.toHaveBeenCalled();
});

it("can read GitHub relay config from runtime environment variables", async () => {
const previousApiBase = process.env.ADE_GITHUB_RELAY_API_BASE_URL;
const previousProjectId = process.env.ADE_GITHUB_RELAY_REMOTE_PROJECT_ID;
Expand Down Expand Up @@ -315,10 +443,12 @@ describe("automationIngressService", () => {
await service.pollNow();

expect(fetchSpy).not.toHaveBeenCalled();
// Missing authorization is an idle "disabled" state (quiet auth-pending
// cooldown), not a recurring error.
expect(updates).toContainEqual(expect.objectContaining({
githubRelay: expect.objectContaining({
healthy: false,
status: "error",
status: "disabled",
lastError: "Authorize the ADE GitHub App with GitHub before using the hosted relay.",
}),
}));
Expand Down
Loading
Loading