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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,7 @@ jobs:
test-desktop:
needs: install
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@ function createRuntime() {
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
productAnalyticsService: {
capture: vi.fn(() => ({ accepted: true, reason: "accepted" })),
identifyAccount: vi.fn(() => ({ accepted: true, reason: "accepted" })),
resetAccountIdentity: vi.fn(() => true),
getStatus: vi.fn(() => ({ configured: true, enabled: true, effective: true })),
setEnabled: vi.fn(),
flush: vi.fn(async () => true),
Expand Down Expand Up @@ -3784,6 +3786,7 @@ describe("adeRpcServer", () => {
expiresAt: "2026-07-15T10:00:00.000Z",
source: "env-token",
});
expect(fixture.runtime.productAnalyticsService.identifyAccount).toHaveBeenCalledWith("user_123");
});

it("rejects product analytics capture from agent-run identities", async () => {
Expand Down
17 changes: 16 additions & 1 deletion apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,11 @@ import type { AdeRuntime } from "./bootstrap";
import { reseedBundledAdeSkillsForCli } from "./bootstrap";
import { EncryptedFileCredentialStore } from "./services/credentials/credentialStore";
import type { AccountMachinePublisherService } from "./services/account/accountMachinePublisherService";
import { shouldRejectDevelopmentEnvCredential } from "./services/account/accountAuthService";
import {
shouldRejectDevelopmentEnvCredential,
syncAccountAnalyticsIdentity,
} from "./services/account/accountAuthService";
import { getSharedAccountAuthService } from "./services/account/sharedAccountAuthService";
import { DEFAULT_SYNC_HOST_PORT } from "./services/sync/syncProtocol";
import {
runAdeCodeRemote,
Expand Down Expand Up @@ -15651,6 +15655,15 @@ async function runServe(
const port = parseOptionalPort(readValue(args, ["--port"]), "--port");
const syncEnabled = !readFlag(args, ["--no-sync"]);
const projectRegistry = new ProjectRegistry(layout);
const brainAccountAuthService = getSharedAccountAuthService({
secretsDir: layout.secretsDir,
projectRoots: () => projectRegistry.list().map((project) => project.rootPath),
logger: headlessProjectLogger,
});
syncAccountAnalyticsIdentity(
brainAccountAuthService.getStatus(),
brainProductAnalytics,
);
const personalChatScope = createPersonalChatScope();
let preferredSyncProjectId: string | null = null;
const preferredSyncProjectRoot = process.env.ADE_PROJECT_ROOT?.trim();
Expand Down Expand Up @@ -16049,6 +16062,8 @@ async function runServe(
projectRegistry,
scopeRegistry,
personalChatScope,
productAnalyticsService: brainProductAnalytics,
Comment thread
arul28 marked this conversation as resolved.
accountAuthService: brainAccountAuthService,
getAccountDirectoryHealth,
getRuntimeStatus: () => {
const publishHealth = getAccountDirectoryHealth();
Expand Down
7 changes: 7 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,10 @@ describe("multi-project RPC server", () => {
it("reconciles account-owned client trust on sign-out and account switch", async () => {
const { registry } = createRegistry();
const accountAuthService = makeAccountAuthServiceMock();
const productAnalyticsService = {
identifyAccount: vi.fn(),
resetAccountIdentity: vi.fn(),
};
const reconcileAccountOwnership = vi.fn();
const previousDefaultRole = process.env.ADE_DEFAULT_ROLE;
process.env.ADE_DEFAULT_ROLE = "cto";
Expand All @@ -499,6 +503,7 @@ describe("multi-project RPC server", () => {
serverVersion: "test",
projectRegistry: registry,
accountAuthService,
productAnalyticsService,
reconcileAccountOwnership,
});
await handler({
Expand All @@ -515,6 +520,7 @@ describe("multi-project RPC server", () => {
params: { action: "signOut", args: {} },
});
expect(reconcileAccountOwnership).toHaveBeenLastCalledWith(null);
expect(productAnalyticsService.resetAccountIdentity).toHaveBeenCalledTimes(1);

(accountAuthService.pollLogin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
status: "signed_in",
Expand All @@ -534,6 +540,7 @@ describe("multi-project RPC server", () => {
params: { action: "pollLogin", args: { sessionId: "test-session" } },
});
expect(reconcileAccountOwnership).toHaveBeenLastCalledWith("account-b");
expect(productAnalyticsService.identifyAccount).toHaveBeenCalledWith("account-b");

(accountAuthService.getStatus as ReturnType<typeof vi.fn>).mockReturnValue({
signedIn: true,
Expand Down
3 changes: 3 additions & 0 deletions apps/ade-cli/src/multiProjectRpcServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from "../../desktop/src/shared/types";
import {
callAccountAction,
type AccountAnalyticsIdentity,
type AccountAuthService,
} from "./services/account/accountAuthService";
import {
Expand Down Expand Up @@ -101,6 +102,7 @@ export type MultiProjectRpcHandlerOptions = {
"capabilities" | "call" | "streamEvents" | "dispose"
> & Partial<Pick<PersonalChatScope, "activitySummary">>;
accountAuthService?: AccountAuthService;
productAnalyticsService?: AccountAnalyticsIdentity;
getAccountDirectoryHealth?: () => SyncAccountDirectoryHealth;
getRuntimeStatus?: () => {
syncPort: number | null;
Expand Down Expand Up @@ -1205,6 +1207,7 @@ export function createMultiProjectRpcRequestHandler(
}
const response = await callAccountAction({
service: accountAuthService,
analytics: options.productAnalyticsService,
action,
actionArgs: isRecord(params.args) ? params.args : {},
});
Expand Down
35 changes: 35 additions & 0 deletions apps/ade-cli/src/services/account/accountAuthService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import type { SyncCredentialStore } from "../credentials/credentialStore";
import {
ACCOUNT_SESSION_CREDENTIAL_KEY,
createAccountActionDomainService,
createAccountAuthService,
derivePkceChallenge,
getSignedInAccountAccessToken,
Expand Down Expand Up @@ -103,6 +104,40 @@ afterEach(() => {
for (const service of activeServices.splice(0)) service.dispose();
});

describe("account action analytics identity", () => {
it("identifies persisted signed-in status and resets identity for signed-out status", () => {
const analytics = {
identifyAccount: vi.fn(),
resetAccountIdentity: vi.fn(),
};
const service = {
getStatus: vi.fn()
.mockReturnValueOnce({
signedIn: true,
userId: "user_persisted",
email: null,
name: null,
expiresAt: null,
})
.mockReturnValueOnce({
signedIn: false,
userId: null,
email: null,
name: null,
expiresAt: null,
}),
} as unknown as AccountAuthService;
const domain = createAccountActionDomainService(service, analytics);

expect(domain.status()).toMatchObject({ signedIn: true, userId: "user_persisted" });
expect(analytics.identifyAccount).toHaveBeenCalledWith("user_persisted");
expect(analytics.resetAccountIdentity).not.toHaveBeenCalled();

expect(domain.status()).toMatchObject({ signedIn: false, userId: null });
expect(analytics.resetAccountIdentity).toHaveBeenCalledTimes(1);
});
});

describe("AccountAuthService persisted session notifications", () => {
it("preserves an unreadable credential-file diagnosis when the account key is absent", () => {
const store = new MemoryCredentialStore();
Expand Down
41 changes: 36 additions & 5 deletions apps/ade-cli/src/services/account/accountAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,11 @@ export type AccountSessionRecord = {
oauthConfig?: AccountOAuthConfig;
};

export type AccountAnalyticsIdentity = {
identifyAccount(userId: string | null | undefined): unknown;
resetAccountIdentity(): unknown;
};

export type AccountAuthSource = "loopback" | "device" | "env-token" | null;

export type AccountIdentityProvider = "github" | "google" | "apple" | "email";
Expand Down Expand Up @@ -648,26 +653,52 @@ function toStatus(record: AccountSessionRecord | null): AccountAuthStatus {
};
}

export function syncAccountAnalyticsIdentity(
status: AccountAuthStatus,
analytics?: AccountAnalyticsIdentity,
): AccountAuthStatus {
if (status.signedIn) {
analytics?.identifyAccount(status.userId);
} else {
analytics?.resetAccountIdentity();
}
return status;
}

export function createAccountActionDomainService(
service: AccountAuthService,
analytics?: AccountAnalyticsIdentity,
): AccountActionDomainService {
return {
startLogin: () => service.startLogin(),
pollLogin: (args) => service.pollLogin(readNonEmptyString(args?.sessionId) ?? ""),
pollLogin: async (args) => {
const result = await service.pollLogin(readNonEmptyString(args?.sessionId) ?? "");
syncAccountAnalyticsIdentity(result.authStatus, analytics);
return result;
},
startDeviceLogin: (args) => service.startDeviceLogin({
ignoreEnvCredential: args?.ignoreEnvCredential === true,
}),
pollDeviceLogin: (args) => service.pollDeviceLogin(readNonEmptyString(args?.sessionId) ?? ""),
status: () => service.getStatus(),
pollDeviceLogin: async (args) => {
const result = await service.pollDeviceLogin(readNonEmptyString(args?.sessionId) ?? "");
syncAccountAnalyticsIdentity(result.authStatus, analytics);
return result;
},
status: () => syncAccountAnalyticsIdentity(service.getStatus(), analytics),
cancelLogin: (args) => service.cancelLogin(readNonEmptyString(args?.sessionId) ?? ""),
signOut: () => service.signOut(),
signOut: () => {
const status = service.signOut();
analytics?.resetAccountIdentity();
return status;
},
getToken: () => service.getAccessToken(),
createToken: () => service.createToken(),
};
}

export async function callAccountAction(args: {
service: AccountAuthService;
analytics?: AccountAnalyticsIdentity;
action: string;
actionArgs?: Record<string, unknown>;
}): Promise<{
Expand All @@ -677,7 +708,7 @@ export async function callAccountAction(args: {
statusHints: Record<string, never>;
}> {
const action = args.action as AccountActionName;
const domain = createAccountActionDomainService(args.service);
const domain = createAccountActionDomainService(args.service, args.analytics);
if (!ACCOUNT_ACTION_NAMES.includes(action)) {
throw new Error(`Action 'account.${args.action}' is not callable.`);
}
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
defaultProductAnalyticsStateFile,
getSharedProductAnalyticsService,
} from "./services/analytics/productAnalyticsService";
import { detectInstallSource } from "./services/analytics/installSource";
import { captureAgentTurnSettledAnalytics } from "./services/analytics/agentTurnProductAnalytics";
import { initPerfRunFromEnv } from "./services/perf/perfLog";
import { startMetricsSampler } from "./services/perf/metricsSampler";
Expand Down Expand Up @@ -1421,6 +1422,18 @@ app.whenReady().then(async () => {
appVersion: app.getVersion(),
runtimeMode: app.isPackaged ? "desktop_packaged" : "desktop_development",
}));
productAnalyticsService.captureInternal({
event: "ade_app_installed",
surface: "desktop",
properties: {
install_source: detectInstallSource({
isPackaged: app.isPackaged,
execPath: process.execPath,
resourcesPath: process.resourcesPath,
configuredSource: process.env.ADE_INSTALL_SOURCE,
}),
},
});
productAnalyticsService.capture({
event: "ade_app_opened",
surface: "desktop",
Expand Down
16 changes: 12 additions & 4 deletions apps/desktop/src/main/services/adeActions/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2345,9 +2345,9 @@ describe("runtime account actions", () => {
expiresAt: "2026-07-14T12:05:00.000Z",
})),
pollLogin: vi.fn(async (sessionId: string) => ({
status: "pending" as const,
status: "signed_in" as const,
message: null,
authStatus: { signedIn: false, userId: null, email: null, name: null, expiresAt: null },
authStatus: { signedIn: true, userId: "account-user", email: null, name: null, expiresAt: null },
sessionId,
})),
startDeviceLogin: vi.fn(async () => ({
Expand All @@ -2366,8 +2366,8 @@ describe("runtime account actions", () => {
sessionId,
})),
getStatus: vi.fn(() => ({
signedIn: false,
userId: null,
signedIn: true,
userId: "account-user",
email: null,
name: null,
expiresAt: null,
Expand All @@ -2388,8 +2388,13 @@ describe("runtime account actions", () => {
})),
dispose: vi.fn(),
};
const productAnalyticsService = {
identifyAccount: vi.fn(),
resetAccountIdentity: vi.fn(),
};
const service = getAdeActionDomainServices({
accountAuthService,
productAnalyticsService,
} as never).account as {
startLogin(): Promise<unknown>;
pollLogin(args: { sessionId: string }): Promise<unknown>;
Expand Down Expand Up @@ -2436,6 +2441,9 @@ describe("runtime account actions", () => {
expect(accountAuthService.signOut).toHaveBeenCalledTimes(1);
expect(accountAuthService.getAccessToken).toHaveBeenCalledTimes(1);
expect(accountAuthService.createToken).toHaveBeenCalledTimes(1);
expect(productAnalyticsService.identifyAccount).toHaveBeenCalledTimes(2);
expect(productAnalyticsService.identifyAccount).toHaveBeenCalledWith("account-user");
expect(productAnalyticsService.resetAccountIdentity).toHaveBeenCalledTimes(2);
expect(isCtoOnlyAdeAction("account", "startLogin")).toBe(true);
expect(isCtoOnlyAdeAction("account", "pollLogin")).toBe(true);
expect(isCtoOnlyAdeAction("account", "startDeviceLogin")).toBe(true);
Expand Down
5 changes: 4 additions & 1 deletion apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3853,7 +3853,10 @@ export function getAdeActionDomainServices(
const automationsEnabled = areAutomationsEnabledForPackagedState(Boolean(runtime.isPackaged));
return {
account: runtime.accountAuthService
? toService(createAccountActionDomainService(runtime.accountAuthService))
? toService(createAccountActionDomainService(
runtime.accountAuthService,
runtime.productAnalyticsService ?? undefined,
))
: null,
attention: toService(buildAttentionDomainService(runtime)),
lane: toService(buildLaneDomainService(runtime)),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,25 @@ export function captureAgentTurnSettledAnalytics(args: {
},
});

if (event.status === "completed") {
analytics.captureInternal({
event: "ade_app_installed",
surface: "api",
properties: {
install_source: "unknown",
},
});
analytics.captureInternal({
event: "ade_activated",
Comment thread
arul28 marked this conversation as resolved.
surface: "api",
projectId,
sessionId: event.sessionId,
properties: {
trigger: "work_session_completed",
},
});
}

if (event.status !== "failed") return;
analytics.captureInternal({
event: "ade_error",
Expand Down
33 changes: 33 additions & 0 deletions apps/desktop/src/main/services/analytics/installSource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { detectInstallSource } from "./installSource";

describe("detectInstallSource", () => {
it("separates development, explicit distribution, Homebrew, and unknown installs", () => {
expect(detectInstallSource({
isPackaged: false,
execPath: "/tmp/ADE",
})).toBe("development");
expect(detectInstallSource({
isPackaged: true,
execPath: "/private/tmp/ADE",
configuredSource: "direct_download",
})).toBe("direct_download");
expect(detectInstallSource({
isPackaged: true,
execPath: "/Applications/ADE.app/Contents/MacOS/ADE",
realpath: () => "/opt/homebrew/Caskroom/ade/1.2.3/ADE.app/Contents/MacOS/ADE",
})).toBe("homebrew");
expect(detectInstallSource({
isPackaged: true,
execPath: "/private/tmp/ADE.app/Contents/MacOS/ADE",
resourcesPath: "/private/tmp/ADE.app/Contents/Resources",
realpath: (candidate) => candidate,
})).toBe("unknown");
expect(detectInstallSource({
isPackaged: true,
execPath: "/Applications/ADE.app/Contents/MacOS/ADE",
resourcesPath: "/Applications/ADE.app/Contents/Resources",
realpath: (candidate) => candidate,
})).toBe("direct_download");
});
});
Loading