import path from 'node:path'; import type { Cookie } from 'playwright'; import { z } from 'zod'; import type { BrowserClient } from '../browser/client.js'; import type { RedditAccountPrivateRecord, RedditAccountsRepository, AccountContentRepository, } from '../database/account-repositories.js'; import type { ConversationOutcomesRepository, ActivityLogRepository, ConversationStateRepository, DeliveryOutboxRepository, MessagesRepository, PostsRepository, RedditUsersRepository, SuccessfulMessagesRepository, } from '../database/repositories.js'; import type { RedditApiClient } from '../reddit/api.js'; import type { RateLimiter } from '../utils/rate-limiter.js'; import type { AccountAuthContext } from './account-auth-context.js'; import type { ChatCache } from './chat/cache.js'; import type { MatrixClient } from './chat/matrix/client.js'; import type { MatrixCredentials } from './chat/matrix/types.js'; import type { ChatService } from './chat/service.js'; import type { MediaService } from './media.js'; import type { MediaSyncService } from './media-sync.js'; import type { MonitorService } from './monitor.js'; import type { ProcessorService } from './processor.js'; import type { ResponseCheckerService } from './response-checker.js'; /** Credentials passed to a factory. They must never be exposed by list/snapshot APIs. */ export interface VerifiedAccountSession { accountId: string; redditUsername: string; matrixUserId: string; cookies: Cookie[]; matrixCredentials: MatrixCredentials; updatedAt: number; } /** * The complete set of stateful dependencies owned by one account runtime. * * Nothing in this object may be shared with another account runtime except the * underlying SQLite connection and stateless configuration/AI clients. In * particular, BrowserClient, AccountAuthContext, ChatCache, MatrixClient, * RedditApiClient and RateLimiter are intentionally per-account instances. */ export interface RedditAccountRuntimeResources { browserProfileDir: string; browserClient: BrowserClient; accountAuth: AccountAuthContext; matrixClient: MatrixClient; chatCache: ChatCache; redditApi: RedditApiClient; postsRepo: PostsRepository; messagesRepo: MessagesRepository; successfulMessagesRepo: SuccessfulMessagesRepository; deliveryOutboxRepo: DeliveryOutboxRepository; conversationStateRepo: ConversationStateRepository; conversationOutcomesRepo: ConversationOutcomesRepository; redditUsersRepo: RedditUsersRepository; accountContentRepo: AccountContentRepository; activityLogRepo: ActivityLogRepository; monitorService: MonitorService; processorService: ProcessorService; responseChecker: ResponseCheckerService; chatService: ChatService; mediaService: MediaService; mediaSyncService: MediaSyncService; rateLimiter: RateLimiter; } export interface RedditAccountRuntimeLifecycle { /** Start this account's main loop, response schedule and media sync. */ start(): void | Promise; /** Stop and await this account's schedules/loop, then close its browser. */ stop(): void | Promise; } export type RedditAccountRuntimeState = | 'created' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed'; export interface RedditAccountRuntimeSnapshot { accountId: string; redditUsername: string; matrixUserId: string; state: RedditAccountRuntimeState; updatedAt: number; failure: string | null; } /** * One immutable Reddit identity and every mutable service associated with it. * There is deliberately no switchAccount method: a session refresh replaces * the matching runtime instead of mutating shared live state. */ export class RedditAccountRuntime { private state: RedditAccountRuntimeState = 'created'; private failure: string | null = null; private transition: Promise = Promise.resolve(); constructor( readonly session: VerifiedAccountSession, readonly resources: RedditAccountRuntimeResources, private readonly lifecycle: RedditAccountRuntimeLifecycle, ) { if (session.accountId !== session.matrixUserId) { throw new Error('Runtime account ID must match the verified Matrix user ID'); } if (!session.accountId || !session.redditUsername) { throw new Error('Runtime requires a verified Reddit and Matrix identity'); } } start(): Promise { return this.enqueue(async () => { if (this.state === 'running') return; this.state = 'starting'; this.failure = null; try { await this.lifecycle.start(); this.state = 'running'; } catch (error) { this.state = 'failed'; this.failure = errorMessage(error); try { await this.lifecycle.stop(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], 'Account runtime failed to start and clean up'); } throw error; } }); } stop(): Promise { return this.enqueue(async () => { if (this.state === 'created' || this.state === 'stopped') { this.state = 'stopped'; return; } this.state = 'stopping'; try { await this.lifecycle.stop(); this.state = 'stopped'; } catch (error) { this.state = 'failed'; this.failure = errorMessage(error); throw error; } }); } snapshot(): RedditAccountRuntimeSnapshot { return { accountId: this.session.accountId, redditUsername: this.session.redditUsername, matrixUserId: this.session.matrixUserId, state: this.state, updatedAt: this.session.updatedAt, failure: this.failure, }; } private enqueue(operation: () => Promise): Promise { const result = this.transition.then(operation, operation); this.transition = result.catch(() => undefined); return result; } } export type RedditAccountRuntimeBuilder = ( session: VerifiedAccountSession, ) => Promise; /** Narrow factory wrapper used as the bootstrap integration seam. */ export class RedditAccountRuntimeFactory { constructor(private readonly buildRuntime: RedditAccountRuntimeBuilder) {} async create(record: RedditAccountPrivateRecord): Promise { return this.buildRuntime(parseStoredSession(record)); } async createSession(session: VerifiedAccountSession): Promise { return this.buildRuntime(session); } } export interface RedditAccountRuntimeManagerSnapshot { runtimes: RedditAccountRuntimeSnapshot[]; } /** * Owns all enabled account runtimes. Work for different account IDs runs in * parallel; operations for the same ID are serialized to avoid duplicate * loops and browser-profile contention. */ export class RedditAccountRuntimeManager { private readonly runtimes = new Map(); private readonly accountOperations = new Map>(); constructor( private readonly accounts: Pick, private readonly factory: RedditAccountRuntimeFactory, ) {} async startEnabled(): Promise { const enabled = this.accounts.listPublic().filter(isEnabledAccount); await Promise.all(enabled.map((account) => this.start(account.accountId))); return this.snapshot(); } start(accountId: string): Promise { return this.forAccount(accountId, async () => { const existing = this.runtimes.get(accountId); if (existing?.snapshot().state === 'running') return; const account = this.requireEnabledAccount(accountId); const runtime = existing ?? await this.factory.create(account); await runtime.start(); this.runtimes.set(accountId, runtime); }); } /** * Rebuild only the refreshed account. Other account loops are untouched. * The old runtime is stopped before concrete construction because a builder * may open the account's persistent browser profile immediately. */ replace(accountId: string): Promise { return this.forAccount(accountId, async () => { const account = this.requireEnabledAccount(accountId); const previous = this.runtimes.get(accountId); if (previous) await previous.stop(); try { // Concrete construction opens the account's persistent browser // profile, so the prior owner must be closed before factory.create. const replacement = await this.factory.create(account); await replacement.start(); this.runtimes.set(accountId, replacement); } catch (error) { if (previous) { try { const restored = await this.factory.createSession(previous.session); await restored.start(); this.runtimes.set(accountId, restored); } catch (rollbackError) { this.runtimes.delete(accountId); throw new AggregateError( [error, rollbackError], `Could not replace or restore Reddit account runtime ${accountId}`, ); } } else { this.runtimes.delete(accountId); } throw error; } }); } stop(accountId: string): Promise { return this.forAccount(accountId, async () => { const runtime = this.runtimes.get(accountId); if (!runtime) return; await runtime.stop(); this.runtimes.delete(accountId); }); } async stopAll(): Promise { await Promise.all([...this.runtimes.keys()].map((accountId) => this.stop(accountId))); } get(accountId: string): RedditAccountRuntime | null { return this.runtimes.get(accountId) ?? null; } list(): RedditAccountRuntimeSnapshot[] { return this.snapshot().runtimes; } snapshot(): RedditAccountRuntimeManagerSnapshot { return { runtimes: [...this.runtimes.values()] .map((runtime) => runtime.snapshot()) .sort((left, right) => left.redditUsername.localeCompare(right.redditUsername)), }; } private requireEnabledAccount(accountId: string): RedditAccountPrivateRecord { const account = this.accounts.get(accountId); if (!account || !isEnabledAccount(account)) { throw new Error(`Reddit account ${accountId} is not enabled with a verified session`); } return account; } private forAccount(accountId: string, operation: () => Promise): Promise { const previous = this.accountOperations.get(accountId) ?? Promise.resolve(); const result = previous.then(operation, operation); const settled = result.catch(() => undefined).finally(() => { if (this.accountOperations.get(accountId) === settled) { this.accountOperations.delete(accountId); } }); this.accountOperations.set(accountId, settled); return result; } } /** * Deterministic, filesystem-safe profile path for one immutable account ID. * Bootstrap should clone config and set browser.sessionDir to this value * before constructing that account's BrowserClient. */ export function accountBrowserProfileDir(baseSessionDir: string, accountId: string): string { if (!accountId) throw new Error('Browser profile requires an account ID'); const encodedAccountId = Buffer.from(accountId, 'utf8').toString('base64url'); return path.join(baseSessionDir, 'accounts', encodedAccountId); } function parseStoredSession(record: RedditAccountPrivateRecord): VerifiedAccountSession { const cookies = StoredCookiesSchema.parse(JSON.parse(record.cookiesJson)) as Cookie[]; const matrixCredentials = StoredMatrixCredentialsSchema.parse( JSON.parse(record.matrixCredentialsJson), ) as MatrixCredentials; if (record.matrixUserId !== record.accountId || matrixCredentials.userId !== record.accountId) { throw new Error('Stored Reddit account and Matrix identities do not match'); } return { accountId: record.accountId, redditUsername: record.redditUsername, matrixUserId: record.accountId, cookies, matrixCredentials, updatedAt: record.updatedAt, }; } const StoredCookiesSchema = z.array(z.object({ name: z.string().min(1), value: z.string(), domain: z.string().optional(), path: z.string().optional(), expires: z.number().optional(), httpOnly: z.boolean().optional(), secure: z.boolean().optional(), sameSite: z.enum(['Strict', 'Lax', 'None']).optional(), url: z.string().url().optional(), })); const StoredMatrixCredentialsSchema = z.object({ homeserver: z.string().url(), accessToken: z.string().min(1), userId: z.string().min(1), capturedAt: z.number(), }); function isEnabledAccount(account: { status: string; isActive: boolean; }): boolean { return account.status === 'verified' && account.isActive; } function errorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); }