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 @@ -1515,6 +1515,8 @@ export async function createAdeRuntime(args: {
logger,
configStore: cloudRelayStore,
getSyncPort: () => resolvedArgs.syncRuntime?.sharedSyncListener?.getPort() ?? null,
getExpectedLoopbackNonce: () =>
resolvedArgs.syncRuntime?.sharedSyncListener?.getExpectedLoopbackNonce() ?? null,
}));
// Only the runtime that actually hosts phone sync (owns the brain-level
// shared listener) may register the relay tunnel. The relay DO keeps ONE
Expand Down Expand Up @@ -1588,6 +1590,7 @@ export async function createAdeRuntime(args: {
remoteCommandExecutor: resolvedArgs.syncRuntime.remoteCommandExecutor,
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
syncTunnelClientService,
onCloudRelayEnabledChanged: (enabled) => {
// Same gate as startup: only the sync-hosting runtime may register
// the relay tunnel (see canHostRelayTunnel above).
Expand Down
75 changes: 75 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3315,6 +3315,81 @@ describe("ADE CLI", () => {
expect(output).toContain("Git repository detected");
});

it("adds sync route health to doctor and names a loopback listener mismatch", () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-cli-doctor-sync-"));
fs.mkdirSync(path.join(projectRoot, ".ade"), { recursive: true });
try {
const plan = expectExecutePlan(buildCliPlan(["doctor"]));
expect(plan.steps).toContainEqual({
key: "syncStatus",
method: "sync.getStatus",
params: { includeTransferReadiness: false },
optional: true,
});
const summary = summarizeExecution({
plan,
connection: {
mode: "runtime-socket",
projectRoot,
workspaceRoot: projectRoot,
socketPath: path.join(projectRoot, ".ade", "ade.sock"),
},
values: {
rpcActions: { actions: [{}] },
actions: { actions: [{}] },
syncStatus: {
pairingConnectInfo: { port: 8787 },
routeHealth: {
listener: {
listenerBound: true,
loopbackAdeValidated: false,
reason: "Expected ADE 426 Upgrade Required; received 404 Not Found.",
},
tailscale: {
enabled: true,
tailscaleReachable: false,
reason: "Tailscale route points at the listener mismatch.",
},
relay: {
enabled: true,
relayControlConnected: true,
relayBridgeValidated: false,
reason: "Relay bridge refused the listener mismatch.",
},
},
},
},
} as any) as Record<string, any>;

expect(summary.sync).toMatchObject({
enabled: true,
usable: false,
status: "warning",
});
expect(summary.sync.failingRoutes).toEqual([
expect.stringContaining("listener"),
expect.stringContaining("tailscale"),
expect.stringContaining("relay"),
]);
expect(summary.sync.message).toContain("404 Not Found");
const output = formatOutput(summary, {
projectRoot,
workspaceRoot: projectRoot,
role: "agent",
headless: false,
requireSocket: false,
socketPath: null,
pretty: true,
text: true,
timeoutMs: 1000,
}, "doctor");
expect(output).toContain("Sync route failure");
expect(output).toContain("listener");
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true });
}
});

it("detects project-local Linear credentials in doctor readiness", () => {
const previousAdeLinearApi = process.env.ADE_LINEAR_API;
const previousLinearApiKey = process.env.LINEAR_API_KEY;
Expand Down
75 changes: 75 additions & 0 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11510,6 +11510,12 @@ function buildCliPlan(
...actionStep("projectConfig", "project_config", "get"),
optional: true,
},
{
key: "syncStatus",
method: "sync.getStatus",
params: { includeTransferReadiness: false },
optional: true,
},
],
};
}
Expand Down Expand Up @@ -12126,6 +12132,70 @@ function checkStorageReadiness(projectRoot: string): ReadinessCheck {
}
}

function checkSyncReadiness(value: unknown): ReadinessCheck & {
enabled: boolean;
usable: boolean;
failingRoutes: string[];
} {
const snapshot = isRecord(value) ? value : null;
const routeHealth = snapshot && isRecord(snapshot.routeHealth) ? snapshot.routeHealth : null;
const listener = routeHealth && isRecord(routeHealth.listener) ? routeHealth.listener : null;
const tailscale = routeHealth && isRecord(routeHealth.tailscale) ? routeHealth.tailscale : null;
const relay = routeHealth && isRecord(routeHealth.relay) ? routeHealth.relay : null;
const enabled = Boolean(snapshot?.pairingConnectInfo) || relay?.enabled === true;
if (!snapshot || !routeHealth) {
return {
ready: false,
enabled: false,
usable: false,
status: "unavailable",
message: "Sync route health is unavailable.",
nextAction: "Run 'ade sync status --text' against the live ADE runtime.",
failingRoutes: [],
};
}
if (!enabled) {
return {
ready: true,
enabled: false,
usable: false,
status: "unavailable",
message: "Phone sync hosting is not enabled in this runtime.",
failingRoutes: [],
details: { routeHealth },
};
}

const failures: string[] = [];
if (listener?.listenerBound !== true || listener?.loopbackAdeValidated !== true) {
failures.push(`listener: ${asString(listener?.reason) ?? "loopback listener mismatch"}`);
}
if (tailscale?.enabled === true && tailscale?.tailscaleReachable !== true) {
failures.push(`tailscale: ${asString(tailscale.reason) ?? "published route is not reachable"}`);
}
if (
relay?.enabled === true
&& (relay?.relayControlConnected !== true || asString(relay?.reason) != null)
) {
failures.push(`relay: ${asString(relay.reason) ?? "control channel is not connected"}`);
}
const usable = failures.length === 0;
return {
ready: usable,
enabled: true,
usable,
status: usable ? "ready" : "warning",
message: usable
? "Enabled sync routes are usable."
: `Sync route failure: ${failures.join("; ")}`,
nextAction: usable
? undefined
: "Run 'ade sync status --text' and resolve the named listener or route failure.",
failingRoutes: failures,
details: { routeHealth },
};
}

function requireAdeLayout(): {
resolveAdeLayout: (projectRoot: string) => { secretsDir: string };
} {
Expand Down Expand Up @@ -12183,6 +12253,7 @@ function buildReadinessSnapshot(args: {
computerUse: checkComputerUseReadiness(),
path: checkPathReadiness(),
storage: checkStorageReadiness(connection.projectRoot),
sync: checkSyncReadiness(values.syncStatus),
};
const recommendations = Object.entries(checks)
.filter(([, check]) => check.nextAction)
Expand Down Expand Up @@ -12258,6 +12329,7 @@ function buildReadinessSnapshot(args: {
computerUse: checks.computerUse,
path: checks.path,
storage: checks.storage,
sync: checks.sync,
auth: {
localProjectAccess: projectInitialized && actions.length > 0,
providerSecretsExposed: false,
Expand Down Expand Up @@ -17064,6 +17136,8 @@ function formatTextOutput(
isRecord(value) && isRecord(value.path) ? value.path : {};
const storage =
isRecord(value) && isRecord(value.storage) ? value.storage : {};
const sync =
isRecord(value) && isRecord(value.sync) ? value.sync : {};
const recommendations =
isRecord(value) && Array.isArray(value.recommendations)
? value.recommendations
Expand All @@ -17087,6 +17161,7 @@ function formatTextOutput(
["computer use", computerUse.message],
["path", pathStatus.message],
["storage", storage.message],
["sync", sync.message],
["recommendation", isRecord(value) ? value.recommendation : null],
]),
...(recommendations.length
Expand Down
Loading