Migrate authService to async ModelRuntime API (slice 1)

Move AuthService off the removed AuthStorage / ModelRegistry.create surface
onto the async ModelRuntime API:

- AuthService.create({ agentDir | runtime }) async factory wrapping
  ModelRuntime.create({ authPath, modelsPath }); createModelRuntimeForAgentDir
  replaces createModelRegistryForAgentDir.
- saveApiKey -> runtime.login(providerId, "api_key", nonInteractive) so the
  key is persisted through the runtime credential store.
- logoutProvider -> runtime.logout; refreshAuthState -> await runtime.refresh().
- startOAuthLogin now passes the runtime into OAuthLoginFlowService.start.
- authProviders/requireOAuthLoginProvider became async around getLogin/Logout
  provider options.
- sessiond.ts: async createRuntime, AuthService.create, pass modelRuntime to
  PiSessionService; sessionDaemonStartup awaits createRuntime.

Cross-slice: authProviderOptions (2), oauthLoginFlowService (3), and
piSessionService (4) still expose the old ModelRegistry shape, so the tree does
not fully typecheck yet. Session-daemon path changed -> manual sessiond restart
needed once the migration lands.
This commit is contained in:
Federico Jaramillo Martinez
2026-07-17 20:53:42 +02:00
parent 585c3bc89d
commit e37148c193
3 changed files with 45 additions and 34 deletions
+3 -3
View File
@@ -36,15 +36,15 @@ await app.register(fastifyWebsocket);
await runSessionDaemonStartup({ await runSessionDaemonStartup({
logger: app.log, logger: app.log,
createRuntime() { async createRuntime() {
const eventHub = new SessionEventHub(); const eventHub = new SessionEventHub();
const workspaceActivity = new WorkspaceActivityService(eventHub); const workspaceActivity = new WorkspaceActivityService(eventHub);
const auth = new AuthService({ agentDir: activeAgentProfile.dir }); const auth = await AuthService.create({ agentDir: activeAgentProfile.dir });
const spawnTargets = config.spawnSessions const spawnTargets = config.spawnSessions
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() }) ? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
: undefined; : undefined;
const sessions = new PiSessionService(eventHub, { const sessions = new PiSessionService(eventHub, {
modelRegistry: auth.modelRegistry, modelRuntime: auth.runtime,
agentDir: activeAgentProfile.dir, agentDir: activeAgentProfile.dir,
workspaceActivity, workspaceActivity,
logger: app.log, logger: app.log,
+2 -2
View File
@@ -12,7 +12,7 @@ export interface SessionDaemonStartupLogger {
export interface SessionDaemonStartupSteps<Runtime> { export interface SessionDaemonStartupSteps<Runtime> {
logger: SessionDaemonStartupLogger; logger: SessionDaemonStartupLogger;
createRuntime(): Runtime; createRuntime(): Runtime | Promise<Runtime>;
registerRoutes(runtime: Runtime): void; registerRoutes(runtime: Runtime): void;
listen(runtime: Runtime): Promise<void>; listen(runtime: Runtime): Promise<void>;
migrateArchive?: () => Promise<LegacySessionArchiveMigrationResult>; migrateArchive?: () => Promise<LegacySessionArchiveMigrationResult>;
@@ -36,7 +36,7 @@ export async function runSessionDaemonStartup<Runtime>(
); );
} }
const runtime = steps.createRuntime(); const runtime = await steps.createRuntime();
steps.registerRoutes(runtime); steps.registerRoutes(runtime);
await steps.listen(runtime); await steps.listen(runtime);
return runtime; return runtime;
+40 -29
View File
@@ -1,5 +1,6 @@
import { join } from "node:path"; import { join } from "node:path";
import { AuthStorage, ModelRegistry } from "@earendil-works/pi-coding-agent"; import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import type { AuthInteraction } from "@earendil-works/pi-ai";
import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js"; import type { AuthProvidersResponse, AuthType, OAuthFlowState } from "../../shared/apiTypes.js";
import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js"; import { getLoginProviderOptions, getLogoutProviderOptions } from "./authProviderOptions.js";
import { OAuthLoginFlowService } from "./oauthLoginFlowService.js"; import { OAuthLoginFlowService } from "./oauthLoginFlowService.js";
@@ -9,27 +10,31 @@ export interface AuthChange {
} }
type AuthChangeListener = (change: AuthChange) => void; type AuthChangeListener = (change: AuthChange) => void;
type ModelRegistryInstance = ReturnType<typeof ModelRegistry.create>;
export interface AuthServiceDependencies { export interface AuthServiceDependencies {
agentDir?: string; agentDir?: string;
modelRegistry?: ModelRegistryInstance; runtime?: ModelRuntime;
authFlows?: OAuthLoginFlowService; authFlows?: OAuthLoginFlowService;
} }
export function createModelRegistryForAgentDir(agentDir: string): ModelRegistryInstance { export function createModelRuntimeForAgentDir(agentDir: string): Promise<ModelRuntime> {
const authStorage = AuthStorage.create(join(agentDir, "auth.json")); return ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
return ModelRegistry.create(authStorage, join(agentDir, "models.json"));
} }
export class AuthService { export class AuthService {
readonly modelRegistry: ModelRegistryInstance; readonly runtime: ModelRuntime;
private readonly authFlows: OAuthLoginFlowService; private readonly authFlows: OAuthLoginFlowService;
private readonly listeners = new Set<AuthChangeListener>(); private readonly listeners = new Set<AuthChangeListener>();
constructor(deps: AuthServiceDependencies = {}) { private constructor(runtime: ModelRuntime, authFlows: OAuthLoginFlowService) {
this.modelRegistry = deps.modelRegistry ?? (deps.agentDir === undefined ? ModelRegistry.create(AuthStorage.create()) : createModelRegistryForAgentDir(deps.agentDir)); this.runtime = runtime;
this.authFlows = deps.authFlows ?? new OAuthLoginFlowService(); this.authFlows = authFlows;
}
static async create(deps: AuthServiceDependencies = {}): Promise<AuthService> {
const runtime = deps.runtime ?? (deps.agentDir === undefined ? await ModelRuntime.create({}) : await createModelRuntimeForAgentDir(deps.agentDir));
const authFlows = deps.authFlows ?? new OAuthLoginFlowService();
return new AuthService(runtime, authFlows);
} }
subscribe(listener: AuthChangeListener): () => void { subscribe(listener: AuthChangeListener): () => void {
@@ -44,33 +49,40 @@ export class AuthService {
this.listeners.clear(); this.listeners.clear();
} }
authProviders(mode: "login" | "logout", authType?: AuthType): AuthProvidersResponse { async authProviders(mode: "login" | "logout", authType?: AuthType): Promise<AuthProvidersResponse> {
this.modelRegistry.refresh(); await this.runtime.refresh();
const providers = mode === "logout" ? getLogoutProviderOptions(this.modelRegistry) : getLoginProviderOptions(this.modelRegistry, authType); const providers = mode === "logout" ? await getLogoutProviderOptions(this.runtime) : await getLoginProviderOptions(this.runtime, authType);
return { providers }; return { providers };
} }
saveApiKey(providerId: string, key: string): { accepted: true } { async saveApiKey(providerId: string, key: string): Promise<{ accepted: true }> {
if (key.trim() === "") throw new Error("API key is required"); if (key.trim() === "") throw new Error("API key is required");
this.modelRegistry.authStorage.set(providerId, { type: "api_key", key }); // The provider's api-key login prompts for the key and persists the returned
this.refreshAuthState(); // credential through the runtime's credential store; feed the key back via a
// non-interactive AuthInteraction.
const interaction: AuthInteraction = {
prompt: async () => key,
notify: () => {},
};
await this.runtime.login(providerId, "api_key", interaction);
await this.refreshAuthState();
return { accepted: true }; return { accepted: true };
} }
logoutProvider(providerId: string): { accepted: true } { async logoutProvider(providerId: string): Promise<{ accepted: true }> {
this.modelRegistry.authStorage.logout(providerId); await this.runtime.logout(providerId);
this.refreshAuthState({ removedProviderId: providerId }); await this.refreshAuthState({ removedProviderId: providerId });
return { accepted: true }; return { accepted: true };
} }
startOAuthLogin(providerId: string): OAuthFlowState { async startOAuthLogin(providerId: string): Promise<OAuthFlowState> {
const provider = this.requireOAuthLoginProvider(providerId); const provider = await this.requireOAuthLoginProvider(providerId);
return this.authFlows.start({ return this.authFlows.start({
providerId, providerId,
providerName: provider.name, providerName: provider.name,
authStorage: this.modelRegistry.authStorage, runtime: this.runtime,
onComplete: () => { onComplete: () => {
this.refreshAuthState(); void this.refreshAuthState();
}, },
}); });
} }
@@ -87,9 +99,8 @@ export class AuthService {
return this.authFlows.cancel(flowId); return this.authFlows.cancel(flowId);
} }
private refreshAuthState(change: AuthChange = {}): void { private async refreshAuthState(change: AuthChange = {}): Promise<void> {
this.modelRegistry.authStorage.reload(); await this.runtime.refresh();
this.modelRegistry.refresh();
this.emit(change); this.emit(change);
} }
@@ -97,9 +108,9 @@ export class AuthService {
for (const listener of this.listeners) listener(change); for (const listener of this.listeners) listener(change);
} }
private requireOAuthLoginProvider(providerId: string) { private async requireOAuthLoginProvider(providerId: string) {
this.modelRegistry.refresh(); await this.runtime.refresh();
const provider = getLoginProviderOptions(this.modelRegistry, "oauth").find((option) => option.id === providerId); const provider = (await getLoginProviderOptions(this.runtime, "oauth")).find((option) => option.id === providerId);
if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`); if (provider === undefined) throw new Error(`OAuth provider not found: ${providerId}`);
return provider; return provider;
} }